From a5968283d998f331605611e24bec8b6808f68fc8 Mon Sep 17 00:00:00 2001 From: Naomi Dushay Date: Thu, 6 Jul 2017 16:01:35 -0700 Subject: [PATCH] v3 manifest: improve specificity, validation and tests --- lib/iiif/v3/presentation/manifest.rb | 59 ++- .../iiif/v3/abstract_resource_spec.rb | 14 +- .../iiif/v3/presentation/manifest_spec.rb | 372 +++++++++++++++--- 3 files changed, 374 insertions(+), 71 deletions(-) diff --git a/lib/iiif/v3/presentation/manifest.rb b/lib/iiif/v3/presentation/manifest.rb index 329bca7..5cb801d 100644 --- a/lib/iiif/v3/presentation/manifest.rb +++ b/lib/iiif/v3/presentation/manifest.rb @@ -3,18 +3,26 @@ module V3 module Presentation class Manifest < IIIF::V3::AbstractResource - TYPE = 'Manifest' + TYPE = 'Manifest'.freeze def required_keys - super + %w{ id label } + super + %w{ id label items } + end + + def prohibited_keys + super + CONTENT_RESOURCE_PROPERTIES + PAGING_PROPERTIES + %w{ start_canvas content_annotation } + end + + def uri_only_keys + super + %w{ id } end def array_only_keys - super + %w{ sequences structures } + super + %w{ items structures } end def legal_viewing_hint_values - %w{ individuals paged continuous auto-advance none } + %w{ individuals paged continuous auto-advance } end def initialize(hsh={}) @@ -23,8 +31,47 @@ def initialize(hsh={}) end def validate - super - # TODO: check types of sequences and structure members + super # also checks navDate format + + unless self['id'] =~ /^https?:/ + err_msg = "id must be an http(s) URI for #{self.class}" + raise IIIF::V3::Presentation::IllegalValueError, err_msg + end + + unless self['items'].size >= 1 + m = 'The items list must have at least one entry (and it must be a IIIF::V3::Presentation::Sequence)' + raise IIIF::V3::Presentation::MissingRequiredKeyError, m + end + + unless self['items'].all? { |entry| entry.instance_of?(IIIF::V3::Presentation::Sequence) } + m = 'All entries in the items list must be a IIIF::V3::Presentation::Sequence' + raise IIIF::V3::Presentation::IllegalValueError, m + end + + default_sequence = self['items'].first + unless default_sequence['items'] && default_sequence['items'].size >=1 && + default_sequence['items'].all? { |entry| entry.instance_of?(IIIF::V3::Presentation::Canvas) } + m = 'The default Sequence (the first entry of "items") must be written out in full within the Manifest file' + raise IIIF::V3::Presentation::IllegalValueError, m + end + + if self['items'].size > 1 + unless self['items'].all? { |entry| entry['label'] } + m = 'If there are multiple Sequences in a manifest then they must each have at least one label' + raise IIIF::V3::Presentation::IllegalValueError, m + end + end + + # TODO: when embedding a sequence without any extensions within a manifest, the sequence must not have the @context field. + + # TODO: AnnotationLists must not be embedded within the manifest + + if self['structures'] + unless self['structures'].all? { |entry| entry.instance_of?(IIIF::V3::Presentation::Range)} + m = 'All entries in the structures list must be a IIIF::V3::Presentation::Range' + raise IIIF::V3::Presentation::IllegalValueError, m + end + end end end end diff --git a/spec/integration/iiif/v3/abstract_resource_spec.rb b/spec/integration/iiif/v3/abstract_resource_spec.rb index e597129..b94cd56 100644 --- a/spec/integration/iiif/v3/abstract_resource_spec.rb +++ b/spec/integration/iiif/v3/abstract_resource_spec.rb @@ -59,7 +59,7 @@ "id": "http://www.example.org/library/catalog/book1.marc", "format": "application/marc" }, - "sequences": [ + "items": [ { "id":"http://www.example.org/iiif/book1/sequence/normal", "type": "Sequence", @@ -121,16 +121,15 @@ expect(parsed.to_ordered_hash.to_a - from_file.to_ordered_hash.to_a).to eq [] expect(from_file.to_ordered_hash.to_a - parsed.to_ordered_hash.to_a).to eq [] end - it 'turns each member of "sequences" into an instance of Sequence' do - expected_klass = IIIF::V3::Presentation::Sequence + it 'turns each member of "items" into an instance of Sequence' do parsed = described_class.from_ordered_hash(fixture) - parsed['sequences'].each do |s| - expect(s.class).to be expected_klass + parsed['items'].each do |s| + expect(s.class).to be IIIF::V3::Presentation::Sequence end end - it 'turns each member of items into an instance of Canvas' do + it 'turns each member of sequences/items into an instance of Canvas' do parsed = described_class.from_ordered_hash(fixture) - parsed['sequences'].each do |s| + parsed['items'].each do |s| s.items.each do |c| expect(c.class).to be IIIF::V3::Presentation::Canvas end @@ -144,7 +143,6 @@ parsed = described_class.from_ordered_hash(fixture) expect(parsed['label']).to eq 'My Manifest' end - end describe '#to_ordered_hash' do diff --git a/spec/unit/iiif/v3/presentation/manifest_spec.rb b/spec/unit/iiif/v3/presentation/manifest_spec.rb index fa1ff9a..84e8d7f 100644 --- a/spec/unit/iiif/v3/presentation/manifest_spec.rb +++ b/spec/unit/iiif/v3/presentation/manifest_spec.rb @@ -1,43 +1,43 @@ describe IIIF::V3::Presentation::Manifest do - let(:subclass_subject) do - Class.new(IIIF::V3::Presentation::Manifest) do - def initialize(hsh={}) - hsh = { 'type' => 'a:SubClass' } - super(hsh) + describe '#required_keys' do + %w{ type id label items }.each do |k| + it k do + expect(subject.required_keys).to include(k) end end end - let(:fixed_values) do - { - 'type' => 'a:SubClass', - 'id' => 'http://example.com/prefix/manifest/123', - 'label' => 'Book 1', - 'description' => 'A longer description of this example book. It should give some real information.', - 'thumbnail' => { - 'id' => 'http://www.example.org/images/book1-page1/full/80,100/0/default.jpg', - 'service'=> { - '@context' => 'http://iiif.io/api/image/2/context.json', - 'id' => 'http://www.example.org/images/book1-page1', - 'profile' => 'http://iiif.io/api/image/2/level1.json' + describe '#prohibited_keys' do + it 'contains the expected key names' do + keys = IIIF::V3::Presentation::Service::CONTENT_RESOURCE_PROPERTIES + + IIIF::V3::Presentation::Service::PAGING_PROPERTIES + + %w{ + start_canvas + content_annotation } - }, - 'attribution' => 'Provided by Example Organization', - 'rights' => 'http://www.example.org/license.html', - 'logo' => 'http://www.example.org/logos/institution1.jpg', - 'see_also' => 'http://www.example.org/library/catalog/book1.xml', - 'service' => { - '@context' => 'http://example.org/ns/jsonld/context.json', - 'id' => 'http://example.org/service/example', - 'profile' => 'http://example.org/docs/example-service.html' - }, - 'related' => { - 'id' => 'http://www.example.org/videos/video-book1.mpg', - 'format' => 'video/mpeg' - }, - 'within' => 'http://www.example.org/collections/books/', - } + expect(subject.prohibited_keys).to include(*keys) + end + end + + describe '#uri_only_keys' do + it 'id' do + expect(subject.uri_only_keys).to include('id') + end + end + + describe '#array_only_keys' do + %w{ items structures}.each do |k| + it k do + expect(subject.array_only_keys).to include(k) + end + end + end + + describe '#legal_viewing_hint_values' do + it 'contains the expected values' do + expect(subject.legal_viewing_hint_values).to contain_exactly('individuals', 'paged', 'continuous', 'auto-advance') + end end describe '#initialize' do @@ -45,43 +45,301 @@ def initialize(hsh={}) expect(subject['type']).to eq 'Manifest' end it 'allows subclasses to override type' do - sub = subclass_subject.new + subclass = Class.new(IIIF::V3::Presentation::Manifest) do + def initialize(hsh={}) + hsh = { 'type' => 'a:SubClass' } + super(hsh) + end + end + sub = subclass.new expect(sub['type']).to eq 'a:SubClass' end end - describe '#required_keys' do - it 'accumulates' do - expect(subject.required_keys).to eq %w{ type id label } - end - end + let(:manifest_id) { 'http://www.example.org/iiif/book1/manifest' } describe '#validate' do - it 'raises an error if there is no id' do + it 'raises an IllegalValueError if id is not http' do subject.label = 'Book 1' - expect { subject.validate }.to raise_error IIIF::V3::Presentation::MissingRequiredKeyError + subject['id'] = 'ftp://www.example.org' + subject['items'] = [IIIF::V3::Presentation::Sequence.new] + exp_err_msg = "id must be an http(s) URI for IIIF::V3::Presentation::Manifest" + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) end - it 'raises an error if there is no label' do - subject['id'] = 'http://www.example.org/iiif/book1/manifest' - expect { subject.validate }.to raise_error IIIF::V3::Presentation::MissingRequiredKeyError + it 'raises MissingRequiredKeyError for items entry without values' do + subject['id'] = manifest_id + subject.label = 'Book 1' + subject['items'] = [] + exp_err_msg = "The items list must have at least one entry (and it must be a IIIF::V3::Presentation::Sequence)" + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::MissingRequiredKeyError, exp_err_msg) end - it 'raises an error if there is no type' do - subject.delete('type') + it 'raises IllegalValueError for items entry that is not a Sequence' do + subject['id'] = manifest_id subject.label = 'Book 1' - subject['id'] = 'http://www.example.org/iiif/book1/manifest' - expect { subject.validate }.to raise_error IIIF::V3::Presentation::MissingRequiredKeyError + subject['items'] = [IIIF::V3::Presentation::Sequence.new, IIIF::V3::Presentation::Canvas.new] + exp_err_msg = "All entries in the items list must be a IIIF::V3::Presentation::Sequence" + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) + end + it 'raises IllegalValueError for default Sequence that is not written out' do + subject['id'] = manifest_id + subject.label = 'Book 1' + seq = IIIF::V3::Presentation::Sequence.new + seq['items'] = [] + subject['items'] = [seq] + exp_err_msg = 'The default Sequence (the first entry of "items") must be written out in full within the Manifest file' + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) + end + it 'raises IllegalValueError for Sequences without "label" if there are multiple Sequences' do + subject['id'] = manifest_id + subject.label = 'Book 1' + seq1 = IIIF::V3::Presentation::Sequence.new + seq1['items'] = [IIIF::V3::Presentation::Canvas.new] + seq2 = IIIF::V3::Presentation::Sequence.new + seq2['label'] = 'label2' + subject['items'] = [seq1, seq2] + exp_err_msg = 'If there are multiple Sequences in a manifest then they must each have at least one label' + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) + end + it 'raises IllegalValueError for structures entry that is not a Range' do + subject['id'] = manifest_id + subject.label = 'Book 1' + seq = IIIF::V3::Presentation::Sequence.new + seq['items'] = [IIIF::V3::Presentation::Canvas.new] + subject['items'] = [seq] + subject['structures'] = [IIIF::V3::Presentation::Sequence.new] + exp_err_msg = "All entries in the structures list must be a IIIF::V3::Presentation::Range" + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) end end - describe "#{described_class}.define_methods_for_array_only_keys" do - it_behaves_like 'it has the appropriate methods for array-only keys v3' - end - - describe "#{described_class}.define_methods_for_string_only_keys" do - it_behaves_like 'it has the appropriate methods for string-only keys v3' - end + describe 'realistic examples' do + let!(:canvas_object) { IIIF::V3::Presentation::Canvas.new({ + "type" => "Canvas", + "id" => "https://example.org/abc666/iiif3/canvas/0001", + "label" => "image", + "height" => 7579, + "width" => 10108, + "content" => [ + { + "type" => "AnnotationPage", + "id" => "https://example.org/abc666/iiif3/annotation_page/0001", + "items" => [ + { + "type" => "Annotation", + "motivation" => "painting", + "id" => "https://example.org/abc666/iiif3/annotation/0001", + "body" => { + "type" => "Image", + "id" => "https://example.org/image/iiif/abc666_05_0001/full/full/0/default.jpg", + "format" => "image/jpeg", + "height" => 7579, + "width" => 10108, + "service" => { + "@context" => "http://iiif.io/api/image/2/context.json", + "@id" => "https://example.org/image/iiif/abc666_05_0001", + "id" => "https://example.org/image/iiif/abc666_05_0001", + "profile" => "http://iiif.io/api/image/2/level1.json" + } + }, + "target" => "https://example.org/abc666/iiif3/canvas/0001" + } + ] + } + ] + })} + let!(:default_sequence_object) {IIIF::V3::Presentation::Sequence.new({ + "id" => "https://example.org/abc666#sequence-1", + "label" => "Current order", + "type" => "Sequence", + "items" => [canvas_object] + })} + describe 'realistic(?) minimal manifest' do + let!(:manifest_object) { described_class.new({ + "@context" => [ + "http://www.w3.org/ns/anno.jsonld", + "http://iiif.io/api/presentation/3/context.json" + ], + "id" => "https://example.org/abc666/iiif3/manifest", + "type" => "Manifest", + "label" => "blah", + "attribution" => "bleah", + "description" => "blargh", + "items" => [default_sequence_object] + })} + it 'validates' do + expect{manifest_object.validate}.not_to raise_error + end + it 'has expected required values' do + expect(manifest_object.type).to eq 'Manifest' + expect(manifest_object.id).to eq "https://example.org/abc666/iiif3/manifest" + expect(manifest_object.label).to eq "blah" + expect(manifest_object.items.size).to be 1 + expect(manifest_object.items.first).to eq default_sequence_object + end + it 'has expected context' do + expect(manifest_object['@context'].size).to be 2 + expect(manifest_object['@context']).to include(*IIIF::V3::Presentation::CONTEXT) + end + it 'has expected string values' do + expect(manifest_object.attribution).to eq "bleah" + expect(manifest_object.description).to eq "blargh" + end + end - describe "#{described_class}.define_methods_for_any_type_keys" do - it_behaves_like 'it has the appropriate methods for any-type keys v3' + describe 'realistic example from Stanford purl manifests' do + let!(:manifest_object) { described_class.new({ + "@context" => [ + "http://www.w3.org/ns/anno.jsonld", + "http://iiif.io/api/presentation/3/context.json" + ], + "id" => "https://example.org/abc666/iiif3/manifest", + "type" => "Manifest", + "label" => "blah", + "attribution" => "bleah", + "description" => "blargh", + "items" => [default_sequence_object], + "logo" => { + "id" => "https://example.org/logo/full/400,/0/default.jpg", + "service" => { + "@context" => "http://iiif.io/api/image/2/context.json", + "@id" => "https://example.org/logo", + "id" => "https://example.org/logo", + "profile" => "http://iiif.io/api/image/2/level1.json" + } + }, + "seeAlso" => { + "id" => "https://example.org/abc666.mods", + "format" => "application/mods+xml" + }, + "metadata" => [ + { + "label" => "Type", + "value" => "map" + }, + { + "label" => "Rights", + "value" => "stuff" + } + ], + "thumbnail" => [{ + "type" => "Image", + "id" => "https://example.org/image/iiif/abc666_05_0001/full/!400,400/0/default.jpg", + "format" => "image/jpeg", + "service" => { + "@context" => "http://iiif.io/api/image/2/context.json", + "@id" => "https://example.org/image/iiif/abc666_05_0001", + "id" => "https://example.org/image/iiif/abc666_05_0001", + "profile" => "http://iiif.io/api/image/2/level1.json" + } + }] + })} + it 'validates' do + expect{manifest_object.validate}.not_to raise_error + end + it 'has expected required values' do + expect(manifest_object.type).to eq 'Manifest' + expect(manifest_object.id).to eq "https://example.org/abc666/iiif3/manifest" + expect(manifest_object.label).to eq "blah" + expect(manifest_object.items.size).to be 1 + expect(manifest_object.items.first).to eq default_sequence_object + end + it 'has expected context' do + expect(manifest_object['@context'].size).to be 2 + expect(manifest_object['@context']).to include(*IIIF::V3::Presentation::CONTEXT) + end + it 'has expected string values' do + expect(manifest_object.attribution).to eq "bleah" + expect(manifest_object.description).to eq "blargh" + end + it 'has expected additional content' do + expect(manifest_object.logo.keys.size).to be 2 + expect(manifest_object.seeAlso.keys.size).to be 2 + expect(manifest_object.metadata.size).to be 2 + expect(manifest_object.thumbnail.size).to be 1 + end + end + describe 'example from http://prezi3.iiif.io/api/presentation/3.0' do + let!(:range_object) { IIIF::V3::Presentation::Range.new({ + "id" => "http://example.org/iiif/book1/range/top", + "label" => "home, home on the", + "type" => "Range", + "viewingHint" => ["top"] + }) + } + let!(:manifest_object) { described_class.new({ + "@context" => [ + "http://www.w3.org/ns/anno.jsonld", + "http://iiif.io/api/presentation/3/context.json" + ], + "id" => "http://example.org/iiif/book1/manifest", + "label" => {"en" => ["Book 1"]}, + "metadata" => [ + {"label" => {"en" => ["Author"]}, + "value" => {"@none" => ["Anne Author"]}}, + {"label" => {"en" => ["Published"]}, + "value" => { + "en" => ["Paris, circa 1400"], + "fr" => ["Paris, environ 1400"]} + }, + {"label" => {"en" => ["Notes"]}, + "value" => {"en" => ["Text of note 1", "Text of note 2"]}}, + {"label" => {"en" => ["Source"]}, + "value" => {"@none" => ["From: Some Collection"]}} + ], + "description" => {"en" => ["A longer description of this example book. It should give some real information."]}, + "thumbnail" => [{ + "id" => "http://example.org/images/book1-page1/full/80,100/0/default.jpg", + "type" => "Image", + "service" => { + "@context" => "http://iiif.io/api/image/2/context.json", + "id" => "http://example.org/images/book1-page1", + "profile" => ["http://iiif.io/api/image/2/level1.json"] + } + }], + "viewingDirection" => "right-to-left", + "viewingHint" => ["paged"], + "navDate" => "1856-01-01T00:00:00Z", + "rights" => [{ + "id" =>"http://example.org/license.html", + "format" => "text/html"}], + "attribution" => {"en" => ["Provided by Example Organization"]}, + "logo" => { + "id" => "http://example.org/logos/institution1.jpg", + "service" => { + "@context" => "http://iiif.io/api/image/2/context.json", + "id" => "http://example.org/service/inst1", + "profile" => ["http://iiif.io/api/image/2/profiles/level2.json"] + } + }, + "related" => [{ + "id" => "http://example.org/videos/video-book1.mpg", + "format" => "video/mpeg" + }], + "service" => [{ + "@context" => "http://example.org/ns/jsonld/context.json", + "id" => "http://example.org/service/example", + "profile" => ["http://example.org/docs/example-service.html"] + }], + "seeAlso" => [{ + "id" => "http://example.org/library/catalog/book1.xml", + "format" => "text/xml", + "profile" => ["http://example.org/profiles/bibliographic"] + }], + "rendering" => [{ + "id" => "http://example.org/iiif/book1.pdf", + "label" => {"en" => ["Download as PDF"]}, + "format" => "application/pdf" + }], + "within" => [{ + "id" => "http://example.org/collections/books/", + "type" => "Collection" + }], + "items" => [default_sequence_object], + "structures" => [range_object] + })} + it 'validates' do + expect{manifest_object.validate}.not_to raise_error + end + end end end