From a1d77a2e0024110c42110cbab95405414c81af73 Mon Sep 17 00:00:00 2001 From: Chris Colvard Date: Fri, 17 Jul 2020 13:27:50 -0400 Subject: [PATCH] Provide patterns and infrastructure for assigning remote identifiers (DOI, Handle, etc.) There are three patterns provided: dispatcher, registrar, and builder Dispatcher - assigns registered iddentifer to a given object Registrar - handles communication with external identifier service Builder - constructs identifer to submit to external identifier service Registrar implementations just need to implement the `registrar!` method. They can be tested with the provided shared spec and then registered with Hyrax by the identifier_registrars configuration (which is generated commented out in the hyrax initializer). identifier_registrars should be a Hash with Symbol keys and Class values. A custom builder implementation can be injected into your registrar by overriding the registrar's initialize method setting the custom builder as the default value for the builder keyword argument. def initialize(builder: MyCustomBuilder.new) super(builder: builder) end With this infrastructure in place, a new remote identifier can be assigned to a work by calling the dispatcher with the work object. Assuming a :datacite registrar has been registered in Hyrax's configuration then this would look like: Hyrax::Identifier::Dispatcher.for(:datacite).assign_for!(object: work) This will set the remote identifier in the work's identifier attribute and save the work. To avoid saving the object use `assign_for` instead. If a different attribute is desired the pass the attribute as a symbol in the :attribute keywork argument to `assign_for!`. This work is ported from mahonia (which had parts ported from epigaea). Both of those implementations were done by @no-reply. Co-authored-by: Tom Johnson --- app/services/hyrax/identifier/builder.rb | 45 ++++++++++++ app/services/hyrax/identifier/dispatcher.rb | 61 +++++++++++++++++ app/services/hyrax/identifier/registrar.rb | 41 +++++++++++ .../templates/config/initializers/hyrax.rb | 5 ++ lib/hyrax/configuration.rb | 5 ++ lib/hyrax/specs/shared_specs.rb | 1 + lib/hyrax/specs/shared_specs/identifiers.rb | 27 ++++++++ spec/lib/hyrax/configuration_spec.rb | 1 + .../services/hyrax/identifier/builder_spec.rb | 44 ++++++++++++ .../hyrax/identifier/dispatcher_spec.rb | 68 +++++++++++++++++++ .../hyrax/identifier/registrar_spec.rb | 37 ++++++++++ 11 files changed, 335 insertions(+) create mode 100644 app/services/hyrax/identifier/builder.rb create mode 100644 app/services/hyrax/identifier/dispatcher.rb create mode 100644 app/services/hyrax/identifier/registrar.rb create mode 100644 lib/hyrax/specs/shared_specs/identifiers.rb create mode 100644 spec/services/hyrax/identifier/builder_spec.rb create mode 100644 spec/services/hyrax/identifier/dispatcher_spec.rb create mode 100644 spec/services/hyrax/identifier/registrar_spec.rb diff --git a/app/services/hyrax/identifier/builder.rb b/app/services/hyrax/identifier/builder.rb new file mode 100644 index 0000000000..f86dd17eb0 --- /dev/null +++ b/app/services/hyrax/identifier/builder.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +module Hyrax + module Identifier + ## + # Builds an identifier string. + # + # Implementations must accept a `prefix:` to `#initialize`, and a `hint:` to + # `#build`. Either or both may be used at the preference of the specific + # implementer or ignored entirely when `#build` is called. + # + # @example + # builder = Hyrax::Identifier::Builder.new(prefix: 'moomin') + # builder.build(hint: '1') # => "moomin/1" + class Builder + ## + # @!attribute prefix [rw] + # @return [String] the prefix to use when building identifiers + attr_accessor :prefix + + ## + # @param prefix [String] the prefix to use when building identifiers + def initialize(prefix: 'pfx') + @prefix = prefix + end + + ## + # @note this default builder requires a `hint` which it appends to the + # prefix to generate the identifier string. + # + # @param hint [#to_s] a string-able object which may be used by the builder + # to generate an identifier. Hints may be required by some builders, while + # others may ignore them to generate an identifier by other means. + # + # @return [String] + # @raise [ArgumentError] if an identifer can't be built from the provided + # hint. + def build(hint: nil) + raise(ArgumentError, "No hint provided to #{self.class}#build") if + hint.nil? + + "#{prefix}/#{hint}" + end + end + end +end diff --git a/app/services/hyrax/identifier/dispatcher.rb b/app/services/hyrax/identifier/dispatcher.rb new file mode 100644 index 0000000000..09510e844c --- /dev/null +++ b/app/services/hyrax/identifier/dispatcher.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true +module Hyrax + module Identifier + class Dispatcher + ## + # @!attribute [rw] registrar + # @return [Hyrax::Identifier::Registrar] + attr_accessor :registrar + + ## + # @param registrar [Hyrax::Identifier::Registrar] + def initialize(registrar:) + @registrar = registrar + end + + class << self + ## + # @param type [Symbol] + # @param registrar_opts [Hash] + # @option registrar_opts [Hyrax::Identifier::Builder] :builder + # + # @return [Hyrax::Identifier::Dispatcher] a dispatcher with an registrar for the + # given type + # @see IdentifierRegistrar.for + def for(type, **registrar_opts) + new(registrar: Hyrax::Identifier::Registrar.for(type, **registrar_opts)) + end + end + + ## + # Assigns an identifier to the object. + # + # This involves two steps: + # - Registering the identifier with the registrar service via `registrar`. + # - Storing the new identifier on the object, in the provided `attribute`. + # + # @note the attribute for identifier storage must be multi-valued, and will + # be overwritten during assignment. + # + # @param attribute [Symbol] the attribute in which to store the identifier. + # This attribute will be overwritten during assignment. + # @param object [ActiveFedora::Base, Hyrax::Resource] the object to assign an identifier. + # + # @return [ActiveFedora::Base, Hyrax::Resource] object + def assign_for(object:, attribute: :identifier) + record = registrar.register!(object: object) + object.public_send("#{attribute}=".to_sym, [record.identifier]) + object + end + + ## + # Assigns an identifier and saves the object. + # + # @see #assign_for + def assign_for!(object:, attribute: :identifier) + assign_for(object: object, attribute: attribute).save! + object + end + end + end +end diff --git a/app/services/hyrax/identifier/registrar.rb b/app/services/hyrax/identifier/registrar.rb new file mode 100644 index 0000000000..fe8c7fa2f3 --- /dev/null +++ b/app/services/hyrax/identifier/registrar.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +module Hyrax + module Identifier + class Registrar + class << self + ## + # @param type [Symbol] + # @param opts [Hash] + # @option opts [Hyrax::Identifier::Builder] :builder + # + # @return [Hyrax::Identifier::Registrar] a registrar for the given type + def for(type, **opts) + return Hyrax.config.identifier_registrars[type].new(**opts) if Hyrax.config.identifier_registrars.include?(type) + raise ArgumentError, "Hyrax::Identifier::Registrar not found to handle #{type}" + end + end + + ## + # @!attribute builder [rw] + # @return [Hyrax::Identifier::Builder] + attr_accessor :builder + + ## + # @param builder [Hyrax::Identifier::Builder] + def initialize(builder:) + @builder = builder + end + + ## + # @abstract + # + # @param object [#id] + # + # @return [#identifier] + # @raise [NotImplementedError] when the method is abstract + def register!(*) + raise NotImplementedError + end + end + end +end diff --git a/lib/generators/hyrax/templates/config/initializers/hyrax.rb b/lib/generators/hyrax/templates/config/initializers/hyrax.rb index 5c53040664..4c6d320a7d 100644 --- a/lib/generators/hyrax/templates/config/initializers/hyrax.rb +++ b/lib/generators/hyrax/templates/config/initializers/hyrax.rb @@ -266,6 +266,11 @@ # mount point. # # config.whitelisted_ingest_dirs = [] + + ## Remote identifiers configuration + # Add registrar implementations by uncommenting and adding to the hash below. + # See app/services/hyrax/identifier/registrar.rb for the registrar interface + # config.identifier_registrars = {} end Date::DATE_FORMATS[:standard] = "%m/%d/%Y" diff --git a/lib/hyrax/configuration.rb b/lib/hyrax/configuration.rb index 3c63813317..5837a7e71a 100644 --- a/lib/hyrax/configuration.rb +++ b/lib/hyrax/configuration.rb @@ -524,6 +524,11 @@ def default_nested_relationship_reindexer ->(id:, extent:) { Samvera::NestingIndexer.reindex_relationships(id: id, extent: extent) } end + attr_writer :identifier_registrars + def identifier_registrars + @identifier_registrars ||= {} + end + private # @param [Symbol, #to_s] model_name - symbol representing the model diff --git a/lib/hyrax/specs/shared_specs.rb b/lib/hyrax/specs/shared_specs.rb index 037b951325..38f9d43456 100644 --- a/lib/hyrax/specs/shared_specs.rb +++ b/lib/hyrax/specs/shared_specs.rb @@ -1,2 +1,3 @@ require 'hyrax/specs/shared_specs/derivative_service' +require 'hyrax/specs/shared_specs/identifiers' require 'hyrax/specs/shared_specs/workflow_method' diff --git a/lib/hyrax/specs/shared_specs/identifiers.rb b/lib/hyrax/specs/shared_specs/identifiers.rb new file mode 100644 index 0000000000..f873744bd6 --- /dev/null +++ b/lib/hyrax/specs/shared_specs/identifiers.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a Hyrax::Identifier::Builder' do + subject(:builder) { described_class.new } + + describe '#build' do + it 'returns an identifier string' do + expect(builder.build(hint: 'moomin')) + .to respond_to :to_str + end + end +end + +RSpec.shared_examples 'a Hyrax::Identifier::Registrar' do + subject(:registrar) { described_class.new(builder: builder) } + let(:builder) { instance_double(Hyrax::Identifier::Builder, build: 'moomin') } + let(:object) { instance_double(GenericWork, id: 'moomin_id') } + + it { is_expected.to have_attributes(builder: builder) } + + describe '#register!' do + it 'creates an identifier record' do + expect(registrar.register!(object: object).identifier) + .to respond_to :to_str + end + end +end diff --git a/spec/lib/hyrax/configuration_spec.rb b/spec/lib/hyrax/configuration_spec.rb index 3cee6857f5..a9f7cf2b8f 100644 --- a/spec/lib/hyrax/configuration_spec.rb +++ b/spec/lib/hyrax/configuration_spec.rb @@ -42,6 +42,7 @@ it { is_expected.to respond_to(:feature_config_path) } it { is_expected.to respond_to(:google_analytics_id?) } it { is_expected.to respond_to(:google_analytics_id) } + it { is_expected.to respond_to(:identifier_registrars) } it { is_expected.to respond_to(:iiif_image_server?) } it { is_expected.to respond_to(:iiif_image_server=) } it { is_expected.to respond_to(:iiif_image_url_builder) } diff --git a/spec/services/hyrax/identifier/builder_spec.rb b/spec/services/hyrax/identifier/builder_spec.rb new file mode 100644 index 0000000000..4189b346f5 --- /dev/null +++ b/spec/services/hyrax/identifier/builder_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'hyrax/specs/shared_specs' + +RSpec.describe Hyrax::Identifier::Builder do + subject(:builder) { described_class.new } + + it_behaves_like 'a Hyrax::Identifier::Builder' + + describe '#prefix' do + it 'has a default prefix' do + expect(builder.prefix).not_to be_empty + end + + it 'accepts a prefix' do + prefix = 'my_pfx' + builder = described_class.new(prefix: prefix) + expect(builder.prefix).to eq prefix + end + end + + describe '#build' do + it 'uses the prefix' do + expect(builder.build(hint: 'blah')).to start_with "#{builder.prefix}/" + end + + context 'with a custom prefix' do + subject(:builder) { described_class.new(prefix: prefix) } + let(:prefix) { 'fake_prefix' } + + it 'uses the prefix' do + expect(builder.build(hint: 'blah')).to start_with "#{prefix}/" + end + end + + it 'raises an error with no hint' do + expect { builder.build }.to raise_error ArgumentError + end + + it 'uses the hint exactly, cast to uppercase' do + expect(builder.build(hint: 'moomin')).to eq 'pfx/moomin' + end + end +end diff --git a/spec/services/hyrax/identifier/dispatcher_spec.rb b/spec/services/hyrax/identifier/dispatcher_spec.rb new file mode 100644 index 0000000000..1e0afb762c --- /dev/null +++ b/spec/services/hyrax/identifier/dispatcher_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe Hyrax::Identifier::Dispatcher do + subject(:dispatcher) { described_class.new(registrar: fake_registrar.new) } + let(:identifier) { 'moomin/123/abc' } + let(:object) { build(:generic_work) } + + let(:fake_registrar) do + Class.new do + def initialize(*); end + + def register!(*) + Struct.new(:identifier).new('moomin/123/abc') + end + end + end + + shared_examples 'performs identifier assignment' do |method| + it 'returns the same object' do + expect(dispatcher.public_send(method, object: object)).to eql object + end + + it 'assigns to the identifier attribute by default' do + dispatcher.public_send(method, object: object) + expect(object.identifier).to contain_exactly(identifier) + end + + it 'assigns to specified attribute when requested' do + dispatcher.public_send(method, object: object, attribute: :keyword) + expect(object.keyword).to contain_exactly(identifier) + end + end + + it 'has a registrar' do + expect(dispatcher.registrar).to be_a fake_registrar + end + + describe '.for' do + before do + allow(Hyrax.config).to receive(:identifier_registrars).and_return({ moomin: fake_registrar }) + end + + it 'chooses the right registrar type' do + expect(described_class.for(:moomin).registrar) + .to be_a fake_registrar + end + + it 'raises an error when a fake registrar type is passes' do + expect { described_class.for(:NOT_A_REAL_TYPE) } + .to raise_error ArgumentError + end + end + + describe '#assign_for' do + include_examples 'performs identifier assignment', :assign_for + end + + describe '#assign_for!' do + include_examples 'performs identifier assignment', :assign_for! + + it 'saves the object' do + expect { dispatcher.assign_for!(object: object) } + .to change { object.new_record? } + .from(true) + .to(false) + end + end +end diff --git a/spec/services/hyrax/identifier/registrar_spec.rb b/spec/services/hyrax/identifier/registrar_spec.rb new file mode 100644 index 0000000000..41d2a1570d --- /dev/null +++ b/spec/services/hyrax/identifier/registrar_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Hyrax::Identifier::Registrar do + subject(:registrar) { described_class.new(builder: :NOT_A_REAL_BUILDER) } + + it 'is abstract' do + expect { registrar.register!(object: :NOT_A_REAL_OBJECT) } + .to raise_error NotImplementedError + end + + describe '.for' do + let(:builder) { instance_double(Hyrax::Identifier::Builder, build: 'moomin') } + let(:fake_registrar) do + Class.new do + def initialize(*); end + + def register!(*) + Struct.new(:identifier).new('moomin/123/abc') + end + end + end + + before do + allow(Hyrax.config).to receive(:identifier_registrars).and_return({ moomin: fake_registrar }) + end + + it 'raises an error when a fake registrar type is passes' do + expect { described_class.for(:NOT_A_REAL_TYPE, builder: builder) } + .to raise_error ArgumentError + end + + it 'chooses the right registrar type' do + expect(described_class.for(:moomin, builder: builder)) + .to be_a fake_registrar + end + end +end