Skip to content

Commit

Permalink
Provide patterns and infrastructure for assigning remote identifiers …
Browse files Browse the repository at this point in the history
…(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 <johnson.tom@gmail.com>
  • Loading branch information
cjcolvar and Tom Johnson committed Jul 21, 2020
1 parent ccfa31c commit a1d77a2
Show file tree
Hide file tree
Showing 11 changed files with 335 additions and 0 deletions.
45 changes: 45 additions & 0 deletions app/services/hyrax/identifier/builder.rb
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions app/services/hyrax/identifier/dispatcher.rb
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions app/services/hyrax/identifier/registrar.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions lib/generators/hyrax/templates/config/initializers/hyrax.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions lib/hyrax/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/hyrax/specs/shared_specs.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
require 'hyrax/specs/shared_specs/derivative_service'
require 'hyrax/specs/shared_specs/identifiers'
require 'hyrax/specs/shared_specs/workflow_method'
27 changes: 27 additions & 0 deletions lib/hyrax/specs/shared_specs/identifiers.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/lib/hyrax/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
44 changes: 44 additions & 0 deletions spec/services/hyrax/identifier/builder_spec.rb
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions spec/services/hyrax/identifier/dispatcher_spec.rb
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions spec/services/hyrax/identifier/registrar_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit a1d77a2

Please sign in to comment.