diff --git a/README.md b/README.md index 6f55ea3..0c8b48b 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Accessors are provided to ease the use of EZID [reserved metadata elements](http Notes: - `_crossref` is an exception because `crossref` is also the name of a metadata profile and a special element. Use `identifier._crossref` to read and `identifier._crossref = value` to write. - Reserved elements which are not user-writeable do not implement writers. -- Special readers are implemented for reserved elements having date/time values -- `_created` and `_updated` -- which convert the string time values of EZID to Ruby `Time` instances. +- Special readers are implemented for reserved elements having date/time values (`_created` and `_updated`) which convert the string time values of EZID to Ruby `Time` instances. **Metadata profile elements** can be read and written using the name of the element, replacing the dot (".") with an underscore: @@ -156,18 +156,7 @@ Notes: => "Image" ``` -**Registering custom metadata elements** - -Custom metadata element accessors can be created by a registration process: - -```ruby -Ezid::Client.configure do |config| - # register the element "custom" - config.metadata.register_element :custom - # register the element "dc.identifier" under the accessor :dc_identifier - config.metadata.register_element :dc_identifier, name: "dc.identifier" -end -``` +Accessors are also implemented for the `crossref`, `datacite`, and `erc` elements as described in the EZID API documentation. **Setting default metadata values** diff --git a/VERSION b/VERSION index 54d1a4f..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.0 +1.0.0 diff --git a/lib/ezid/metadata.rb b/lib/ezid/metadata.rb index 2277ab8..d3a1330 100644 --- a/lib/ezid/metadata.rb +++ b/lib/ezid/metadata.rb @@ -7,6 +7,39 @@ module Ezid # @api private # class Metadata < SimpleDelegator + + class << self + def metadata_reader(method, alias_as=nil) + define_method method do + self[method.to_s] + end + if alias_as + alias_method alias_as, method + end + end + + def metadata_writer(method, alias_as=nil) + define_method "#{method}=" do |value| + self[method.to_s] = value + end + if alias_as + alias_method "#{alias_as}=".to_sym, "#{method}=".to_sym + end + end + + def metadata_accessor(method, alias_as=nil) + metadata_reader method, alias_as + metadata_writer method, alias_as + end + + def metadata_profile(profile, *methods) + methods.each do |method| + element = [profile, method].join(".") + alias_as = [profile, method].join("_") + metadata_accessor element, alias_as + end + end + end # EZID metadata field/value separator ANVL_SEPARATOR = ": " @@ -34,195 +67,97 @@ class Metadata < SimpleDelegator # A line ending LINE_ENDING_RE = /\r?\n/ - # A metadata element - Element = Struct.new(:name, :reader, :writer) - - # Metadata profiles - PROFILES = { - "dc" => %w( creator title publisher date type ).freeze, - "datacite" => %w( creator title publisher publicationyear resourcetype ).freeze, - "erc" => %w( who what when ).freeze, - "crossref" => [].freeze - }.freeze - - # EZID reserved metadata elements that have time values - # @see http://ezid.cdlib.org/doc/apidoc.html#internal-metadata - RESERVED_TIME_ELEMENTS = %w( _created _updated ) - # EZID reserved metadata elements that are read-only # @see http://ezid.cdlib.org/doc/apidoc.html#internal-metadata - RESERVED_READONLY_ELEMENTS = %w( _owner _ownergroup _shadows _shadowedby _datacenter _created _updated ) - - # EZID reserved metadata elements that may be set by clients - # @see http://ezid.cdlib.org/doc/apidoc.html#internal-metadata - RESERVED_READWRITE_ELEMENTS = %w( _coowners _target _profile _status _export _crossref ) - - # All EZID reserved metadata elements - # @see http://ezid.cdlib.org/doc/apidoc.html#internal-metadata - RESERVED_ELEMENTS = RESERVED_READONLY_ELEMENTS + RESERVED_READWRITE_ELEMENTS - - def self.initialize! - register_elements - end - - def self.registered_elements - @@registered_elements ||= {} - end - - def self.register_elements - register_profile_elements - register_reserved_elements - end - - def self.register_element(accessor, opts={}) - if element = registered_elements[accessor.to_sym] - raise Error, "Element \"#{element.name}\" is registered under the accessor :#{accessor}." - end - element = Element.new(opts.fetch(:name, accessor.to_s)) - element.reader = define_reader(accessor, element.name) - element.writer = define_writer(accessor, element.name) if opts.fetch(:writer, true) - registered_elements[accessor.to_sym] = element - end - - def self.unregister_element(accessor) - element = registered_elements.delete(accessor) - raise Error, "No element is registered under the accessor :#{accessor}." unless element - remove_method(element.reader) - remove_method(element.writer) if element.writer - end - - def self.register_profile_element(profile, element) - register_element("#{profile}_#{element}", name: "#{profile}.#{element}") - end - - def self.register_profile_elements(profile = nil) - if profile - PROFILES[profile].each { |element| register_profile_element(profile, element) } - else - PROFILES.keys.each do |profile| - register_profile_elements(profile) - register_element(profile) unless profile == "dc" - end - end - end + READONLY = %w( _owner _ownergroup _shadows _shadowedby _datacenter _created _updated ) + + metadata_accessor :_coowners, :coowners + metadata_accessor :_crossref + metadata_accessor :_export, :export + metadata_accessor :_profile, :profile + metadata_accessor :_status, :status + metadata_accessor :_target, :target + + metadata_accessor :crossref + metadata_accessor :datacite + metadata_accessor :erc + + metadata_reader :_created + metadata_reader :_datacenter, :datacenter + metadata_reader :_owner, :owner + metadata_reader :_ownergroup, :ownergroup + metadata_reader :_shadowedby, :shadowedby + metadata_reader :_shadows, :shadows + metadata_reader :_updated + + metadata_profile :dc, :creator, :title, :publisher, :date, :type + metadata_profile :datacite, :creator, :title, :publisher, :publicationyear, :resourcetype + metadata_profile :erc, :who, :what, :when - def self.register_reserved_elements - RESERVED_ELEMENTS.each do |element| - accessor = (element == "_crossref") ? element : element.sub("_", "") - register_element(accessor, name: element, writer: RESERVED_READWRITE_ELEMENTS.include?(element)) - end + def initialize(data={}) + super coerce(data) end - def self.define_reader(accessor, element) - define_method(accessor) do - reader(element) - end + def created + to_time _created end - def self.define_writer(accessor, element) - define_method("#{accessor}=") do |value| - writer(element, value) - end - end - - private_class_method :register_elements, - :register_reserved_elements, - :register_profile_elements, - :unregister_element, - :define_reader, - :define_writer - - def initialize(data={}) - super(coerce(data)) + def updated + to_time _updated end # Output metadata in EZID ANVL format # @see http://ezid.cdlib.org/doc/apidoc.html#request-response-bodies # @return [String] the ANVL output def to_anvl(include_readonly = true) - elements = __getobj__.dup # copy, don't modify! - elements.reject! { |k, v| RESERVED_READONLY_ELEMENTS.include?(k) } unless include_readonly - escape_elements(elements).map { |e| e.join(ANVL_SEPARATOR) }.join("\n") + hsh = __getobj__.dup + hsh.reject! { |k, v| READONLY.include?(k) } unless include_readonly + elements = hsh.map do |name, value| + element = [escape(ESCAPE_NAMES_RE, name), escape(ESCAPE_VALUES_RE, value)] + element.join(ANVL_SEPARATOR) + end + elements.join("\n").force_encoding(Encoding::UTF_8) end def to_s to_anvl end - def registered_elements - self.class.registered_elements - end - private - def reader(element) - value = self[element] - if RESERVED_TIME_ELEMENTS.include?(element) - time = value.to_i - value = (time == 0) ? nil : Time.at(time).utc - end - value - end - - def writer(element, value) - self[element] = value - end - - # Coerce data into a Hash of elements - def coerce(data) - data.to_h - rescue NoMethodError - coerce_string(data) - end - - # Escape elements hash keys and values - def escape_elements(hsh) - hsh.each_with_object({}) do |(n, v), memo| - memo[escape_name(n)] = escape_value(v) - end - end - - # Escape an element name - def escape_name(n) - escape(ESCAPE_NAMES_RE, n) - end + def to_time(value) + time = value.to_i + (time == 0) ? nil : Time.at(time).utc + end - # Escape an element value - def escape_value(v) - escape(ESCAPE_VALUES_RE, v) - end + # Coerce data into a Hash of elements + def coerce(data) + data.to_h + rescue NoMethodError + coerce_string(data) + end - # Escape string for sending to EZID host - # @see http://ezid.cdlib.org/doc/apidoc.html#request-response-bodies - # @param re [Regexp] the regular expression to match for escaping - # @param s [String] the string to escape - # @return [String] the escaped string - def escape(re, s) - s.gsub(re) { |m| URI.encode_www_form_component(m.force_encoding(Encoding::UTF_8)) } - end + # Escape string for sending to EZID host + def escape(regexp, value) + value.gsub(regexp) { |m| URI.encode_www_form_component(m.force_encoding(Encoding::UTF_8)) } + end - # Unescape value from EZID host (or other source) - # @see http://ezid.cdlib.org/doc/apidoc.html#request-response-bodies - # @param value [String] the value to unescape - # @return [String] the unescaped value - def unescape(value) - value.gsub(UNESCAPE_RE) { |m| URI.decode_www_form_component(m) } - end + # Unescape value from EZID host (or other source) + def unescape(value) + value.gsub(UNESCAPE_RE) { |m| URI.decode_www_form_component(m) } + end - # Coerce a string of metadata (e.g., from EZID host) into a Hash - # @note EZID host does not send comments or line continuations. - # @param data [String] the string to coerce - # @return [Hash] the hash of coerced data - def coerce_string(data) - data.gsub!(COMMENT_RE, "") - data.gsub!(LINE_CONTINUATION_RE, " ") - data.split(LINE_ENDING_RE).each_with_object({}) do |line, memo| - element, value = line.split(ANVL_SEPARATOR, 2) - memo[unescape(element.strip)] = unescape(value.strip) - end + # Coerce a string of metadata (e.g., from EZID host) into a Hash + # @note EZID host does not send comments or line continuations. + def coerce_string(data) + data.gsub!(COMMENT_RE, "") + data.gsub!(LINE_CONTINUATION_RE, " ") + data.split(LINE_ENDING_RE).each_with_object({}) do |line, memo| + element, value = line.split(ANVL_SEPARATOR, 2) + memo[unescape(element.strip)] = unescape(value.strip) end + end end end -Ezid::Metadata.initialize! diff --git a/lib/ezid/requests/batch_download_request.rb b/lib/ezid/requests/batch_download_request.rb index 6ecfdca..ee51cdd 100644 --- a/lib/ezid/requests/batch_download_request.rb +++ b/lib/ezid/requests/batch_download_request.rb @@ -7,14 +7,8 @@ class BatchDownloadRequest < Request self.path = "/download_request" self.response_class = BatchDownloadResponse - attr_reader :params - def initialize(client, params={}) - @params = params super - end - - def customize_request set_form_data(params) end diff --git a/lib/ezid/requests/request.rb b/lib/ezid/requests/request.rb index 04baf7f..8ac8f78 100644 --- a/lib/ezid/requests/request.rb +++ b/lib/ezid/requests/request.rb @@ -42,7 +42,7 @@ def short_name def initialize(client, *args) @client = client super build_request - customize_request + set_content_type("text/plain", charset: "UTF-8") end # Executes the request and returns the response @@ -97,10 +97,6 @@ def get_response_for_request end end - def customize_request - set_content_type("text/plain", charset: "UTF-8") - end - def build_request self.class.http_method.new(uri) end diff --git a/spec/unit/identifier_spec.rb b/spec/unit/identifier_spec.rb index 7d6d5fe..d24e802 100644 --- a/spec/unit/identifier_spec.rb +++ b/spec/unit/identifier_spec.rb @@ -150,7 +150,7 @@ module Ezid allow(subject).to receive(:persisted?) { true } end it "should modify the identifier" do - expect(subject.client).to receive(:modify_identifier).with("id", {}) { double(id: "id") } + expect(subject.client).to receive(:modify_identifier).with("id", subject.metadata) { double(id: "id") } subject.save end end @@ -161,7 +161,7 @@ module Ezid context "and `id' is present" do before { allow(subject).to receive(:id) { "id" } } it "should create the identifier" do - expect(subject.client).to receive(:create_identifier).with("id", {}) { double(id: "id") } + expect(subject.client).to receive(:create_identifier).with("id", subject.metadata) { double(id: "id") } subject.save end end @@ -169,7 +169,7 @@ module Ezid context "and `shoulder' is present" do before { allow(subject).to receive(:shoulder) { TEST_ARK_SHOULDER } } it "should mint the identifier" do - expect(subject.client).to receive(:mint_identifier).with(TEST_ARK_SHOULDER, {}) { double(id: "id") } + expect(subject.client).to receive(:mint_identifier).with(TEST_ARK_SHOULDER, subject.metadata) { double(id: "id") } subject.save end end diff --git a/spec/unit/metadata_spec.rb b/spec/unit/metadata_spec.rb index c07d68e..6794861 100644 --- a/spec/unit/metadata_spec.rb +++ b/spec/unit/metadata_spec.rb @@ -1,82 +1,152 @@ module Ezid RSpec.describe Metadata do - describe "reserved elements" do - describe "readers" do - Metadata::RESERVED_ELEMENTS.each do |element| - it "should have a reader for '#{element}'" do - expect(subject).to receive(:reader).with(element) - reader = (element == "_crossref") ? element : element.sub("_", "") - subject.send(reader) - end + describe "metadata accessors and aliases" do + shared_examples "a metadata writer" do |writer| + it "writes the \"#{writer}\" element" do + subject.send("#{writer}=", "value") + expect(subject[writer.to_s]).to eq("value") end - describe "for time-based elements" do - Metadata::RESERVED_TIME_ELEMENTS.each do |element| - context "\"#{element}\"" do - before { subject[element] = "1416507086" } - it "should have a reader than returns a Time instance" do - expect(subject).to receive(:reader).with(element).and_call_original - expect(subject.send(element.sub("_", ""))).to eq(Time.parse("2014-11-20 13:11:26 -0500")) - end - end - end + end + + shared_examples "a metadata reader" do |reader| + it "reads the \"#{reader}\" element" do + subject[reader.to_s] = "value" + expect(subject.send(reader)).to eq("value") end end - describe "writers" do - Metadata::RESERVED_READWRITE_ELEMENTS.each do |element| - next if element == "_crossref" - it "should have a writer for '#{element}'" do - expect(subject).to receive(:writer).with(element, "value") - writer = ((element == "_crossref") ? element : element.sub("_", "")).concat("=") - subject.send(writer, "value") - end + + shared_examples "a metadata reader with an alias" do |reader, aliased_as| + it_behaves_like "a metadata reader", reader + it "has a reader alias \"#{aliased_as}\"" do + subject[reader.to_s] = "value" + expect(subject.send(aliased_as)).to eq("value") + end + end + + shared_examples "a metadata writer with an alias" do |writer, aliased_as| + it_behaves_like "a metadata writer", writer + it "has a writer alias \"#{aliased_as}\"" do + subject.send("#{aliased_as}=", "value") + expect(subject.send(writer)).to eq("value") end end - end - describe "metadata profiles" do - Metadata::PROFILES.each do |profile, elements| - describe "the '#{profile}' metadata profile" do - describe "readers" do - elements.each do |element| - it "should have a reader for '#{profile}.#{element}'" do - expect(subject).to receive(:reader).with("#{profile}.#{element}") - subject.send("#{profile}_#{element}") - end - end - end - describe "writers" do - elements.each do |element| - it "should have a writer for '#{profile}.#{element}'" do - expect(subject).to receive(:writer).with("#{profile}.#{element}", "value") - subject.send("#{profile}_#{element}=", "value") - end - end - end - next if profile == "dc" - it "should have a reader for '#{profile}'" do - expect(subject).to receive(:reader).with(profile) - subject.send(profile) - end - it "should have a writer for '#{profile}'" do - expect(subject).to receive(:writer).with(profile, "value") - subject.send("#{profile}=", "value") - end + shared_examples "a metadata accessor" do |accessor| + it_behaves_like "a metadata reader", accessor + it_behaves_like "a metadata writer", accessor + end + + shared_examples "a metadata accessor with an alias" do |accessor, aliased_as| + it_behaves_like "a metadata reader with an alias", accessor, aliased_as + it_behaves_like "a metadata writer with an alias", accessor, aliased_as + end + + shared_examples "a time reader alias" do |element, aliased_as| + before { subject[element.to_s] = "1416507086" } + it "should return the Time value for the element" do + expect(subject.send(aliased_as)).to eq(Time.parse("2014-11-20 13:11:26 -0500")) end end - end - describe "custom element" do - let(:element) { Metadata::Element.new("custom", true) } - before { described_class.register_element :custom } - after { described_class.send(:unregister_element, :custom) } - it "should have a reader" do - expect(subject).to receive(:reader).with("custom") - subject.custom - end - it "should have a writer" do - expect(subject).to receive(:writer).with("custom", "value") - subject.custom = "value" + shared_examples "a metadata profile accessor with an alias" do |profile, accessor| + it_behaves_like "a metadata accessor with an alias", [profile, accessor].join("."), [profile, accessor].join("_") + end + + describe "_owner" do + it_behaves_like "a metadata reader with an alias", :_owner, :owner + end + describe "_ownergroup" do + it_behaves_like "a metadata reader with an alias", :_ownergroup, :ownergroup + end + describe "_shadows" do + it_behaves_like "a metadata reader with an alias", :_shadows, :shadows + end + describe "_shadowedby" do + it_behaves_like "a metadata reader with an alias", :_shadowedby, :shadowedby + end + describe "_datacenter" do + it_behaves_like "a metadata reader with an alias", :_datacenter, :datacenter + end + + describe "_coowners" do + it_behaves_like "a metadata accessor with an alias", :_coowners, :coowners + end + describe "_target" do + it_behaves_like "a metadata accessor with an alias", :_target, :target + end + describe "_profile" do + it_behaves_like "a metadata accessor with an alias", :_profile, :profile + end + describe "_status" do + it_behaves_like "a metadata accessor with an alias", :_status, :status + end + describe "_export" do + it_behaves_like "a metadata accessor with an alias", :_export, :export + end + + describe "_created" do + it_behaves_like "a metadata reader", :_created + it_behaves_like "a time reader alias", :_created, :created + end + describe "_updated" do + it_behaves_like "a metadata reader", :_updated + it_behaves_like "a time reader alias", :_updated, :updated + end + + describe "erc" do + it_behaves_like "a metadata accessor", :erc + end + describe "datacite" do + it_behaves_like "a metadata accessor", :datacite + end + describe "_crossref" do + it_behaves_like "a metadata accessor", :_crossref + end + describe "crossref" do + it_behaves_like "a metadata accessor", :crossref + end + + describe "dc.creator" do + it_behaves_like "a metadata profile accessor with an alias", :dc, :creator + end + describe "dc.title" do + it_behaves_like "a metadata profile accessor with an alias", :dc, :title + end + describe "dc.publisher" do + it_behaves_like "a metadata profile accessor with an alias", :dc, :publisher + end + describe "dc.date" do + it_behaves_like "a metadata profile accessor with an alias", :dc, :date + end + describe "dc.type" do + it_behaves_like "a metadata profile accessor with an alias", :dc, :type + end + + describe "datacite.creator" do + it_behaves_like "a metadata profile accessor with an alias", :datacite, :creator + end + describe "datacite.title" do + it_behaves_like "a metadata profile accessor with an alias", :datacite, :title + end + describe "datacite.publisher" do + it_behaves_like "a metadata profile accessor with an alias", :datacite, :publisher + end + describe "datacite.publicationyear" do + it_behaves_like "a metadata profile accessor with an alias", :datacite, :publicationyear + end + describe "datacite.resourcetype" do + it_behaves_like "a metadata profile accessor with an alias", :datacite, :resourcetype + end + + describe "erc.who" do + it_behaves_like "a metadata profile accessor with an alias", :erc, :who + end + describe "erc.what" do + it_behaves_like "a metadata profile accessor with an alias", :erc, :what + end + describe "erc.when" do + it_behaves_like "a metadata profile accessor with an alias", :erc, :when end end @@ -95,7 +165,7 @@ module Ezid end describe "encoding" do before do - subject.each_key { |k| subject[k] = subject[k].force_encoding(Encoding::US_ASCII) } + subject.each { |k, v| subject[k] = v.force_encoding(Encoding::US_ASCII) } end it "should be encoded in UTF-8" do expect(subject.to_anvl.encoding).to eq(Encoding::UTF_8) @@ -107,8 +177,8 @@ module Ezid subject { described_class.new(data) } context "of nil" do let(:data) { nil } - it "should create an empty hash" do - expect(subject).to eq({}) + it "should create be empty" do + expect(subject).to be_empty end end context "of a string" do