hyrax-doi 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +65 -0
  3. data/Rakefile +23 -0
  4. data/app/actors/hyrax/actors/doi_actor.rb +50 -0
  5. data/app/assets/config/hyrax_doi_manifest.js +2 -0
  6. data/app/assets/javascripts/hyrax/doi/application.js +15 -0
  7. data/app/assets/stylesheets/hyrax/doi/application.css +15 -0
  8. data/app/controllers/hyrax/doi/application_controller.rb +15 -0
  9. data/app/controllers/hyrax/doi/hyrax_doi_controller.rb +91 -0
  10. data/app/forms/concerns/hyrax/doi/datacite_doi_form_behavior.rb +18 -0
  11. data/app/forms/concerns/hyrax/doi/doi_form_behavior.rb +18 -0
  12. data/app/helpers/hyrax/doi/helper_behavior.rb +9 -0
  13. data/app/helpers/hyrax/doi/work_form_helper.rb +15 -0
  14. data/app/helpers/hyrax/doi/work_show_helper.rb +12 -0
  15. data/app/jobs/hyrax/doi/application_job.rb +7 -0
  16. data/app/jobs/hyrax/doi/register_doi_job.rb +18 -0
  17. data/app/models/concerns/hyrax/doi/datacite_doi_behavior.rb +21 -0
  18. data/app/models/concerns/hyrax/doi/doi_behavior.rb +38 -0
  19. data/app/models/concerns/hyrax/doi/solr_document/datacite_doi_behavior.rb +14 -0
  20. data/app/models/concerns/hyrax/doi/solr_document/doi_behavior.rb +14 -0
  21. data/app/presenters/concerns/hyrax/doi/datacite_doi_presenter_behavior.rb +20 -0
  22. data/app/presenters/concerns/hyrax/doi/doi_presenter_behavior.rb +12 -0
  23. data/app/services/bolognese/readers/hyrax_work_reader.rb +99 -0
  24. data/app/services/bolognese/writers/hyrax_work_writer.rb +50 -0
  25. data/app/services/hyrax/doi/datacite_client.rb +138 -0
  26. data/app/services/hyrax/doi/datacite_registrar.rb +121 -0
  27. data/app/views/hyrax/base/_attribute_rows.html.erb +18 -0
  28. data/app/views/hyrax/base/_form_doi.html.erb +73 -0
  29. data/config/locales/hyrax_doi.en.yml +7 -0
  30. data/config/routes.rb +5 -0
  31. data/lib/generators/hyrax/doi/add_to_work_type_generator.rb +97 -0
  32. data/lib/generators/hyrax/doi/install_generator.rb +74 -0
  33. data/lib/generators/hyrax/doi/templates/config/initializers/hyrax-doi.rb +15 -0
  34. data/lib/hyrax/doi.rb +9 -0
  35. data/lib/hyrax/doi/engine.rb +28 -0
  36. data/lib/hyrax/doi/errors.rb +8 -0
  37. data/lib/hyrax/doi/spec/shared_specs.rb +9 -0
  38. data/lib/hyrax/doi/spec/shared_specs/datacite_doi_behavior.rb +40 -0
  39. data/lib/hyrax/doi/spec/shared_specs/datacite_doi_form_behavior.rb +17 -0
  40. data/lib/hyrax/doi/spec/shared_specs/datacite_doi_presenter_behavior.rb +39 -0
  41. data/lib/hyrax/doi/spec/shared_specs/doi_behavior.rb +63 -0
  42. data/lib/hyrax/doi/spec/shared_specs/doi_form_behavior.rb +17 -0
  43. data/lib/hyrax/doi/spec/shared_specs/doi_presenter_behavior.rb +19 -0
  44. data/lib/hyrax/doi/spec/shared_specs/solr_document/datacite_doi_behavior.rb +20 -0
  45. data/lib/hyrax/doi/spec/shared_specs/solr_document/doi_behavior.rb +20 -0
  46. data/lib/hyrax/doi/version.rb +6 -0
  47. data/lib/tasks/hyrax/doi_tasks.rake +5 -0
  48. metadata +319 -0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module Hyrax
3
+ module DOI
4
+ module SolrDocument
5
+ module DataCiteDOIBehavior
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ attribute :doi_status_when_public, ::SolrDocument::Solr::String, "doi_status_when_public_ssi"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module Hyrax
3
+ module DOI
4
+ module SolrDocument
5
+ module DOIBehavior
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ attribute :doi, ::SolrDocument::Solr::String, "doi_ssi"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ module Hyrax
3
+ module DOI
4
+ module DataCiteDOIPresenterBehavior
5
+ extend ActiveSupport::Concern
6
+
7
+ delegate :doi_status_when_public, to: :solr_document
8
+
9
+ # Should this make a request to DataCite?
10
+ # Or maybe DataCite could supply badges?
11
+ def doi_status
12
+ if doi_status_when_public == 'findable' && !solr_document.public?
13
+ 'registered'
14
+ else
15
+ doi_status_when_public
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module Hyrax
3
+ module DOI
4
+ module DOIPresenterBehavior
5
+ extend ActiveSupport::Concern
6
+
7
+ def doi
8
+ solr_document.doi.present? ? "https://doi.org/#{solr_document.doi}" : nil
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+ require 'bolognese'
3
+
4
+ module Bolognese
5
+ module Readers
6
+ # Use this with Bolognese like the following:
7
+ # m = Bolognese::Metadata.new(input: work.attributes.merge(has_model: work.has_model.first).to_json, from: 'hyrax_work')
8
+ # Then crosswalk it with:
9
+ # m.datacite
10
+ # Or:
11
+ # m.ris
12
+ module HyraxWorkReader
13
+ # Not usable right now given how Metadata#initialize works
14
+ # def get_hyrax_work(id: nil, **options)
15
+ # work = ActiveFedora::Base.find(id)
16
+ # { "string" => work.attributes.merge(has_model: work.has_model).to_json }
17
+ # end
18
+
19
+ def read_hyrax_work(string: nil, **options)
20
+ read_options = ActiveSupport::HashWithIndifferentAccess.new(options.except(:doi, :id, :url, :sandbox, :validate, :ra))
21
+
22
+ meta = string.present? ? Maremma.from_json(string) : {}
23
+
24
+ {
25
+ # "id" => meta.fetch('id', nil),
26
+ "identifiers" => read_hyrax_work_identifiers(meta),
27
+ "types" => read_hyrax_work_types(meta),
28
+ "doi" => normalize_doi(meta.fetch('doi', nil)&.first),
29
+ # "url" => normalize_id(meta.fetch("URL", nil)),
30
+ "titles" => read_hyrax_work_titles(meta),
31
+ "creators" => read_hyrax_work_creators(meta),
32
+ "contributors" => read_hyrax_work_contributors(meta),
33
+ # "container" => container,
34
+ "publisher" => read_hyrax_work_publisher(meta),
35
+ # "related_identifiers" => related_identifiers,
36
+ # "dates" => dates,
37
+ "publication_year" => read_hyrax_work_publication_year(meta),
38
+ "descriptions" => read_hyrax_work_descriptions(meta),
39
+ # "rights_list" => rights_list,
40
+ # "version_info" => meta.fetch("version", nil),
41
+ "subjects" => read_hyrax_work_subjects(meta)
42
+ # "state" => state
43
+ }.merge(read_options)
44
+ end
45
+
46
+ private
47
+
48
+ def read_hyrax_work_types(meta)
49
+ # TODO: Map work.resource_type or work.
50
+ resource_type_general = "Other"
51
+ hyrax_resource_type = meta.fetch('has_model', nil) || "Work"
52
+ resource_type = meta.fetch('resource_type', nil).presence || hyrax_resource_type
53
+ {
54
+ "resourceTypeGeneral" => resource_type_general,
55
+ "resourceType" => resource_type,
56
+ "hyrax" => hyrax_resource_type
57
+ }.compact
58
+ end
59
+
60
+ def read_hyrax_work_creators(meta)
61
+ get_authors(Array.wrap(meta.fetch("creator", nil))) if meta.fetch("creator", nil).present?
62
+ end
63
+
64
+ def read_hyrax_work_contributors(meta)
65
+ get_authors(Array.wrap(meta.fetch("contributor", nil))) if meta.fetch("contributor", nil).present?
66
+ end
67
+
68
+ def read_hyrax_work_titles(meta)
69
+ Array.wrap(meta.fetch("title", nil)).select(&:present?).collect { |r| { "title" => sanitize(r) } }
70
+ end
71
+
72
+ def read_hyrax_work_descriptions(meta)
73
+ Array.wrap(meta.fetch("description", nil)).select(&:present?).collect { |r| { "description" => sanitize(r) } }
74
+ end
75
+
76
+ def read_hyrax_work_publication_year(meta)
77
+ # FIXME: better parsing of free text dates...maybe using EDTF?
78
+ date = meta.fetch("date_created", nil)&.first
79
+ date ||= meta.fetch("date_uploaded", nil)
80
+ Date.parse(date.to_s).year
81
+ rescue Date::Error
82
+ Time.zone.today.year
83
+ end
84
+
85
+ def read_hyrax_work_subjects(meta)
86
+ Array.wrap(meta.fetch("keyword", nil)).select(&:present?).collect { |r| { "subject" => sanitize(r) } }
87
+ end
88
+
89
+ def read_hyrax_work_identifiers(meta)
90
+ Array.wrap(meta.fetch("identifier", nil)).select(&:present?).collect { |r| { "identifier" => sanitize(r) } }
91
+ end
92
+
93
+ def read_hyrax_work_publisher(meta)
94
+ # Fallback to ':unav' since this is a required field for datacite
95
+ parse_attributes(meta.fetch("publisher")).to_s.strip.presence || ":unav"
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+ require 'bolognese'
3
+
4
+ module Bolognese
5
+ module Writers
6
+ # Use this with Bolognese like the following:
7
+ # m = Bolognese::Metadata.new(input: '10.18130/v3-k4an-w022')
8
+ # Then crosswalk it with:
9
+ # m.hyrax_work
10
+ module HyraxWorkWriter
11
+ def hyrax_work
12
+ attributes = {
13
+ 'identifier' => Array(identifiers).select { |id| id["identifierType"] != "DOI" }.pluck("identifier"),
14
+ 'doi' => build_hyrax_work_doi,
15
+ 'title' => titles&.pluck("title"),
16
+ # FIXME: This may not roundtrip since datacite normalizes the creator name
17
+ 'creator' => creators&.pluck("name"),
18
+ 'contributor' => contributors&.pluck("name"),
19
+ 'publisher' => Array(publisher),
20
+ 'date_created' => Array(publication_year),
21
+ 'description' => descriptions&.pluck("description"),
22
+ 'keyword' => subjects&.pluck("subject")
23
+ }
24
+ hyrax_work_class = determine_hyrax_work_class
25
+ # Only pass attributes that the work type knows about
26
+ hyrax_work_class.new(attributes.slice(*hyrax_work_class.attribute_names))
27
+ end
28
+
29
+ private
30
+
31
+ def determine_hyrax_work_class
32
+ # Need to check that the class `responds_to? :doi`?
33
+ types["hyrax"]&.safe_constantize || build_hyrax_work_class
34
+ end
35
+
36
+ def build_hyrax_work_class
37
+ Class.new(ActiveFedora::Base).tap do |c|
38
+ c.include ::Hyrax::WorkBehavior
39
+ c.include ::Hyrax::DOI::DOIBehavior
40
+ # Put BasicMetadata include last since it finalizes the metadata schema
41
+ c.include ::Hyrax::BasicMetadata
42
+ end
43
+ end
44
+
45
+ def build_hyrax_work_doi
46
+ Array(doi&.sub('https://doi.org/', ''))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+ module Hyrax
3
+ module DOI
4
+ class DataCiteClient
5
+ attr_reader :username, :password, :prefix, :mode
6
+
7
+ TEST_BASE_URL = "https://api.test.datacite.org/"
8
+ TEST_MDS_BASE_URL = "https://mds.test.datacite.org/"
9
+ PRODUCTION_BASE_URL = "https://api.datacite.org"
10
+ PRODUCTION_MDS_BASE_URL = "https://mds.datacite.org/"
11
+
12
+ def initialize(username:, password:, prefix:, mode: :production)
13
+ @username = username
14
+ @password = password
15
+ @prefix = prefix
16
+ @mode = mode
17
+ end
18
+
19
+ # Mint a draft DOI without metadata or a url
20
+ # If you already have a DOI and want to register it as a draft then go through the normal process (put_metadata/register_url)
21
+ def create_draft_doi
22
+ # Use regular api instead of mds for metadata-less url-less draft doi creation
23
+ response = connection.post('dois', draft_doi_payload.to_json, "Content-Type" => "application/json")
24
+ raise Error.new('Failed creating draft DOI', response) unless response.status == 201
25
+
26
+ JSON.parse(response.body)['data']['id']
27
+ end
28
+
29
+ def delete_draft_doi(doi)
30
+ response = mds_connection.delete("doi/#{doi}")
31
+ raise Error.new('Failed deleting draft DOI', response) unless response.status == 200
32
+
33
+ doi
34
+ end
35
+
36
+ def get_metadata(doi)
37
+ response = mds_connection.get("metadata/#{doi}")
38
+ raise Error.new('Failed getting DOI metadata', response) unless response.status == 200
39
+
40
+ Nokogiri::XML(response.body).remove_namespaces!
41
+ end
42
+
43
+ # This will mint a new draft DOI if the passed doi parameter is blank
44
+ # The passed datacite xml needs an identifier (just the prefix when minting new DOIs)
45
+ # Beware: This will convert registered DOIs into findable!
46
+ def put_metadata(doi, metadata)
47
+ doi = prefix if doi.blank?
48
+ response = mds_connection.put("metadata/#{doi}", metadata, { 'Content-Type': 'application/xml;charset=UTF-8' })
49
+ raise Error.new('Failed creating metadata for DOI', response) unless response.status == 201
50
+
51
+ /^OK \((?<found_or_created_doi>.*)\)$/ =~ response.body
52
+ found_or_created_doi
53
+ end
54
+
55
+ # Beware: This will make findable DOIs become registered (by setting is_active to false)
56
+ # Otherwise this has no effect on the DOI's metadata (even when draft)
57
+ # Beware: Attempts to delete the metadata of an unknown DOI will actually create a blank draft DOI
58
+ def delete_metadata(doi)
59
+ response = mds_connection.delete("metadata/#{doi}")
60
+ raise Error.new('Failed deleting DOI metadata', response) unless response.status == 200
61
+
62
+ doi
63
+ end
64
+
65
+ def get_url(doi)
66
+ response = mds_connection.get("doi/#{doi}")
67
+ raise Error.new('Failed getting DOI url', response) unless response.status == 200
68
+
69
+ response.body
70
+ end
71
+
72
+ # Beware: This will convert draft DOIs to findable!
73
+ # Metadata needs to be registered for a DOI before a url can be registered
74
+ def register_url(doi, url)
75
+ payload = "doi=#{doi}\nurl=#{url}"
76
+ response = mds_connection.put("doi/#{doi}", payload, { 'Content-Type': 'text/plain;charset=UTF-8' })
77
+ raise Error.new('Failed registering url for DOI', response) unless response.status == 201
78
+
79
+ url
80
+ end
81
+
82
+ class Error < RuntimeError
83
+ ##
84
+ # @!attribute [r] status
85
+ # @return [Integer]
86
+ attr_reader :status
87
+
88
+ ##
89
+ # @param msg [String]
90
+ # @param response [Faraday::Response]
91
+ def initialize(msg = '', response = nil)
92
+ if response
93
+ @status = response.status
94
+ msg += "\n#{@status}: #{response.reason_phrase}\n"
95
+ msg += response.body
96
+ end
97
+
98
+ super(msg)
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def connection
105
+ Faraday.new(url: base_url) do |c|
106
+ c.basic_auth(username, password)
107
+ c.adapter(Faraday.default_adapter)
108
+ end
109
+ end
110
+
111
+ def mds_connection
112
+ Faraday.new(url: mds_base_url) do |c|
113
+ c.basic_auth(username, password)
114
+ c.adapter(Faraday.default_adapter)
115
+ end
116
+ end
117
+
118
+ def draft_doi_payload
119
+ {
120
+ "data": {
121
+ "type": "dois",
122
+ "attributes": {
123
+ "prefix": prefix
124
+ }
125
+ }
126
+ }
127
+ end
128
+
129
+ def base_url
130
+ mode == :production ? PRODUCTION_BASE_URL : TEST_BASE_URL
131
+ end
132
+
133
+ def mds_base_url
134
+ mode == :production ? PRODUCTION_MDS_BASE_URL : TEST_MDS_BASE_URL
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+ module Hyrax
3
+ module DOI
4
+ class DataCiteRegistrar < Hyrax::Identifier::Registrar
5
+ STATES = %w[draft registered findable].freeze
6
+
7
+ # FIXME: make this configurable in a different way so tenants can have different configs in Hyku
8
+ class_attribute :prefix, :username, :password, :mode
9
+
10
+ def initialize(builder: Hyrax::Identifier::Builder.new(prefix: self.prefix))
11
+ super
12
+ end
13
+
14
+ ##
15
+ # @param object [#id]
16
+ #
17
+ # @return [#identifier]
18
+ def register!(object: work)
19
+ doi = Array(object.try(:doi)).first
20
+
21
+ # Return the existing DOI or nil if nothing needs to be done
22
+ return Struct.new(:identifier).new(doi) unless register?(object)
23
+
24
+ # Create a draft DOI (if necessary)
25
+ doi ||= mint_draft_doi
26
+
27
+ # Submit metadata, register url, and ensure proper status
28
+ submit_to_datacite(object, doi)
29
+
30
+ # Return the doi (old or new)
31
+ Struct.new(:identifier).new(doi)
32
+ end
33
+
34
+ def mint_draft_doi
35
+ client.create_draft_doi
36
+ end
37
+
38
+ private
39
+
40
+ # Should the work be submitted for registration (or updating)?
41
+ # @return [boolean]
42
+ def register?(work)
43
+ doi_enabled_work_type?(work) &&
44
+ doi_minting_enabled? &&
45
+ work.doi_status_when_public.in?(Hyrax::DOI::DataCiteRegistrar::STATES)
46
+ # TODO: add more checks here to catch cases when updating is unnecessary
47
+ # TODO: check that required metadata is present if set to registered or findable
48
+ end
49
+
50
+ # Check if work is DOI enabled
51
+ def doi_enabled_work_type?(work)
52
+ work.class.ancestors.include?(Hyrax::DOI::DOIBehavior) && work.class.ancestors.include?(Hyrax::DOI::DataCiteDOIBehavior)
53
+ end
54
+
55
+ def doi_minting_enabled?
56
+ # TODO: Check feature flipper (needs to be per work type? per tenant for Hyku?)
57
+ true
58
+ end
59
+
60
+ def public?(work)
61
+ work.visibility == Hydra::AccessControls::AccessRight::VISIBILITY_TEXT_VALUE_PUBLIC
62
+ end
63
+
64
+ def client
65
+ @client ||= Hyrax::DOI::DataCiteClient.new(username: self.username, password: self.password, prefix: self.prefix, mode: mode)
66
+ end
67
+
68
+ # Do the heavy lifting of submitting the metadata, registering the url, and ensuring the correct status
69
+ def submit_to_datacite(work, doi)
70
+ # 1. Add metadata to the DOI (or update it)
71
+ # TODO: check that required metadata is present if current DOI record is registered or findable OR handle error?
72
+ client.put_metadata(doi, work_to_datacite_xml(work))
73
+
74
+ # 2. Register a url with the DOI if it should be registered or findable
75
+ client.register_url(doi, work_url(work)) if work.doi_status_when_public.in?(['registered', 'findable'])
76
+
77
+ # 3. Always call delete metadata unless findable and public
78
+ # Do this because it has no real effect on the metadata and
79
+ # the put_metadata or register_url above may have made it findable.
80
+ client.delete_metadata(doi) unless work.doi_status_when_public == 'findable' && public?(work)
81
+ end
82
+
83
+ # NOTE: default_url_options[:host] must be set for this method to work
84
+ def work_url(work)
85
+ Rails.application.routes.url_helpers.polymorphic_url(work)
86
+ end
87
+
88
+ def work_to_datacite_xml(work)
89
+ Bolognese::Metadata.new(input: work.attributes.merge(has_model: work.has_model.first).to_json, from: 'hyrax_work').datacite
90
+ end
91
+
92
+ ## Unused methods for now but may be brought in later when filling in TODOs
93
+
94
+ # Fetch the DOI information from DataCite
95
+ # def datacite_record(work)
96
+ # # TODO: Add some level of caching (could be memoization)
97
+ # # TODO: Add error handling?
98
+ # Bolognese::Metadata(input: Array(work.doi).first)
99
+ # end
100
+
101
+ # # Check if metadata sent to the registrar has changed
102
+ # def metadata_changed?(work)
103
+ # fields_to_watch = %w[title creator publisher resource_type identifier description]
104
+ # diff_work = datacite_record.hyrax_work
105
+ # diff_work.update_attributes(work.attributes.slice(**fields_to_watch))
106
+ # diff_work.changes.keys.any? { |k| k.in? fields_to_watch }
107
+ # end
108
+
109
+ # # Check if the status in datacite matches the expected status
110
+ # # except when work is not public and doi_status_when_public is findable
111
+ # def status_needs_updating?(work)
112
+ # current_status = datacite_record.status
113
+ # expected_status = work.doi_status_when_public
114
+ #
115
+ # return false if expected_status == :findable && current_status == :registered && !is_public?(work)
116
+ #
117
+ # current_status != expected_status
118
+ # end
119
+ end
120
+ end
121
+ end