hyrax-doi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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