From 4a6be8eb9464f426c06a1ae4f297db482f2e3af7 Mon Sep 17 00:00:00 2001 From: Naomi Dushay Date: Tue, 25 Jul 2017 09:54:15 -0700 Subject: [PATCH 1/4] v3 abstract_resource: beef up validate_uri slightly --- lib/iiif/v3/abstract_resource.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/iiif/v3/abstract_resource.rb b/lib/iiif/v3/abstract_resource.rb index d507af3..38554a5 100644 --- a/lib/iiif/v3/abstract_resource.rb +++ b/lib/iiif/v3/abstract_resource.rb @@ -500,7 +500,7 @@ def define_accessor_methods(*keys, &validation) private def validate_uri(val, key) - unless val.kind_of?(String) && val =~ URI::regexp + unless val.kind_of?(String) && val =~ /\A#{URI::regexp}\z/ m = "#{key} value must be a String containing a URI for #{self.class}" raise IIIF::V3::Presentation::IllegalValueError, m end From e095e4a633328febd0d158de7b76885c60f48679 Mon Sep 17 00:00:00 2001 From: Naomi Dushay Date: Tue, 25 Jul 2017 18:14:29 -0700 Subject: [PATCH 2/4] v3 choice: improve specificity and tests --- lib/iiif/v3/presentation/choice.rb | 12 +- spec/unit/iiif/v3/presentation/choice_spec.rb | 115 +++++++++++++++++- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/lib/iiif/v3/presentation/choice.rb b/lib/iiif/v3/presentation/choice.rb index 693923f..092477d 100644 --- a/lib/iiif/v3/presentation/choice.rb +++ b/lib/iiif/v3/presentation/choice.rb @@ -3,7 +3,16 @@ module V3 module Presentation class Choice < IIIF::V3::AbstractResource - TYPE = 'Choice' + TYPE = 'Choice'.freeze + + def prohibited_keys + super + CONTENT_RESOURCE_PROPERTIES + PAGING_PROPERTIES + + %w{ nav_date viewing_direction start_canvas content_annotations } + end + + def any_type_keys + super + %w{ default } + end def string_only_keys super + %w{ choice_hint } @@ -29,7 +38,6 @@ def initialize(hsh={}) def validate super - # time mode values if self.has_key?('choice_hint') unless self.legal_choice_hint_values.include?(self['choice_hint']) m = "choiceHint for #{self.class} must be one of #{self.legal_choice_hint_values}." diff --git a/spec/unit/iiif/v3/presentation/choice_spec.rb b/spec/unit/iiif/v3/presentation/choice_spec.rb index 0ffc7f3..0f76f8e 100644 --- a/spec/unit/iiif/v3/presentation/choice_spec.rb +++ b/spec/unit/iiif/v3/presentation/choice_spec.rb @@ -1,17 +1,120 @@ describe IIIF::V3::Presentation::Choice do - describe "#{described_class}.define_methods_for_array_only_keys" do - it_behaves_like 'it has the appropriate methods for array-only keys v3' + describe '#prohibited_keys' do + it 'contains the expected key names' do + keys = described_class::PAGING_PROPERTIES + + described_class::CONTENT_RESOURCE_PROPERTIES + + %w{ + nav_date + viewing_direction + start_canvas + content_annotations + } + expect(subject.prohibited_keys).to include(*keys) + end end - describe "#{described_class}.define_methods_for_string_only_keys" do - it_behaves_like 'it has the appropriate methods for string-only keys v3' + describe '#any_type_keys' do + it 'default' do + expect(subject.any_type_keys).to include('default') + end + end + + describe '#string_only_keys' do + it 'choice_hint' do + expect(subject.string_only_keys).to include('choice_hint') + end + end + + describe '#array_only_keys' do + it 'items' do + expect(subject.array_only_keys).to include('items') + end + end + + describe '#legal_choice_hint_values' do + it 'contains the expected values' do + expect(subject.legal_choice_hint_values).to contain_exactly('client', 'user') + end + end + + describe '#legal_viewing_hint_values' do + it 'contains none' do + expect(subject.legal_viewing_hint_values).to contain_exactly('none') + end + end + + describe '#initialize' do + it 'sets type to Choice by default' do + expect(subject['type']).to eq 'Choice' + end + it 'allows subclasses to override type' do + subclass = Class.new(described_class) do + def initialize(hsh={}) + hsh = { 'type' => 'a:SubClass' } + super(hsh) + end + end + sub = subclass.new + expect(sub['type']).to eq 'a:SubClass' + end + it 'allows type to be passed in' do + my_choice = described_class.new('type' => 'bar') + expect(my_choice.type).to eq 'bar' + end end describe '#validate' do - it 'raises an error if choice_hint isn\'t an allowable value' do + it 'raises an IllegalValueError if choice_hint isn\'t an allowable value' do + exp_err_msg = "choiceHint for #{described_class} must be one of [\"client\", \"user\"]." subject['choice_hint'] = 'foo' - expect { subject.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError + expect { subject.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, exp_err_msg + end + end + + describe 'realistic examples' do + describe 'from digerati' do + let(:item_type) { 'Video' } + let(:item1_id) { 'http://example.org/foo.mp4f' } + let(:item1_mime) { 'video/mp4' } + let(:item1_res) { IIIF::V3::Presentation::Resource.new( + 'id' => item1_id, + 'type' => item_type, + 'format' => item1_mime + )} + let(:item2_id) { 'http://example.org/foo.webm' } + let(:item2_mime) { 'video/webm' } + let(:item2_res) { IIIF::V3::Presentation::Resource.new( + 'id' => item2_id, + 'type' => item_type, + 'format' => item2_mime + )} + let(:choice) { IIIF::V3::Presentation::Choice.new( + 'choiceHint' => 'client', + 'items' => [item1_res, item2_res] + )} + it 'validates' do + expect{choice.validate}.not_to raise_error + end + it 'has expected required values' do + expect(choice['type']).to eq 'Choice' + end + it 'has expected additional values' do + expect(choice.id).to be_nil + expect(choice['choice_hint']).to eq 'client' + expect(choice.choiceHint).to eq 'client' + expect(choice['items']).to eq [item1_res, item2_res] + first = choice['items'].first + expect(first.keys.size).to eq 3 + expect(first['id']).to eq item1_id + expect(first['type']).to eq item_type + expect(first['format']).to eq item1_mime + second = choice['items'].last + expect(second.keys.size).to eq 3 + expect(second['id']).to eq item2_id + expect(second['type']).to eq item_type + expect(second['format']).to eq item2_mime + end end end end From 360e2c7e84c613b6471d9cb4a11298a7090599e7 Mon Sep 17 00:00:00 2001 From: Naomi Dushay Date: Mon, 24 Jul 2017 16:05:52 -0700 Subject: [PATCH 3/4] v3 annotation: increase specificity, improve validation and tests --- lib/iiif/v3/presentation/annotation.rb | 52 ++- .../iiif/v3/presentation/annotation_spec.rb | 404 +++++++++++++++++- 2 files changed, 445 insertions(+), 11 deletions(-) diff --git a/lib/iiif/v3/presentation/annotation.rb b/lib/iiif/v3/presentation/annotation.rb index d19fe71..69a88fa 100644 --- a/lib/iiif/v3/presentation/annotation.rb +++ b/lib/iiif/v3/presentation/annotation.rb @@ -3,22 +3,35 @@ module V3 module Presentation class Annotation < IIIF::V3::AbstractResource - TYPE = 'Annotation' + TYPE = 'Annotation'.freeze def required_keys - super + %w{ motivation } + super + %w{ id motivation target } end - def abstract_resource_only_keys - super + [ { key: 'body', type: IIIF::V3::Presentation::Resource } ] + def prohibited_keys + super + CONTENT_RESOURCE_PROPERTIES + PAGING_PROPERTIES + + %w{ nav_date viewing_direction start_canvas content_annotations } + end + + def any_type_keys + super + %w{ body } + end + + def uri_only_keys + super + %w{ id } end def string_only_keys - super + %w{ time_mode } + super + %w{ motivation time_mode } end def legal_time_mode_values - %w{ trim scale loop } + %w{ trim scale loop }.freeze + end + + def legal_viewing_hint_values + super + %w{ none } end def initialize(hsh={}) @@ -30,7 +43,32 @@ def initialize(hsh={}) def validate super - # time mode values + if self.has_key?('body') && self['body'].kind_of?(IIIF::V3::Presentation::ImageResource) + img_res_class_str = "IIIF::V3::Presentation::ImageResource" + + unless self.motivation == 'painting' + m = "#{self.class} motivation must be 'painting' when body is a kind of #{img_res_class_str}" + raise IIIF::V3::Presentation::IllegalValueError, m + end + + body_resource = self['body'] + body_id = body_resource['id'] + if body_id && body_id =~ /^https?:/ + validate_uri(body_id, 'anno body ImageResource id') # can raise IllegalValueError + else + m = "when #{self.class} body is a kind of #{img_res_class_str}, ImageResource id must be an http(s) URI" + raise IIIF::V3::Presentation::IllegalValueError, m + end + + body_service = *body_resource['service'] + body_service_context = *body_resource['service']['@context'] + expected_context = IIIF::V3::Presentation::Service::IIIF_IMAGE_V2_CONTEXT + unless body_service && body_service_context && body_service_context.include?(expected_context) + m = "when #{self.class} body is a kind of #{img_res_class_str}, ImageResource's service @context must include #{expected_context}" + raise IIIF::V3::Presentation::IllegalValueError, m + end + end + if self.has_key?('time_mode') unless self.legal_time_mode_values.include?(self['time_mode']) m = "timeMode for #{self.class} must be one of #{self.legal_time_mode_values}." diff --git a/spec/unit/iiif/v3/presentation/annotation_spec.rb b/spec/unit/iiif/v3/presentation/annotation_spec.rb index 11e541a..3be6ec3 100644 --- a/spec/unit/iiif/v3/presentation/annotation_spec.rb +++ b/spec/unit/iiif/v3/presentation/annotation_spec.rb @@ -1,13 +1,409 @@ describe IIIF::V3::Presentation::Annotation do - describe "#{described_class}.define_methods_for_abstract_resource_only_keys" do - it_behaves_like 'it has the appropriate methods for abstract_resource_only_keys v3' + let(:content_id) { 'http://example.org/iiif/book1/res/tei-text-p1.xml' } + let(:content_type) { 'dctypes:Text' } + let(:mimetype) { 'application/tei+xml' } + let(:image_2_api_service) { IIIF::V3::Presentation::Service.new({ + '@context' => IIIF::V3::Presentation::Service::IIIF_IMAGE_V2_CONTEXT, + 'id' => content_id, + '@id' => content_id, + 'profile' => IIIF::V3::Presentation::Service::IIIF_IMAGE_V2_LEVEL1_PROFILE + })} + let(:img_content_resource) { IIIF::V3::Presentation::ImageResource.new( + 'id' => content_id, + 'type' => content_type, + 'format' => mimetype, + 'service' => image_2_api_service + )} + + describe '#required_keys' do + %w{ type id motivation target }.each do |k| + it k do + expect(subject.required_keys).to include(k) + end + end + end + + describe '#prohibited_keys' do + it 'contains the expected key names' do + keys = described_class::PAGING_PROPERTIES + + described_class::CONTENT_RESOURCE_PROPERTIES + + %w{ + nav_date + viewing_direction + start_canvas + content_annotations + } + expect(subject.prohibited_keys).to include(*keys) + end + end + + describe '#any_type_keys' do + it 'body' do + expect(subject.any_type_keys).to include('body') + end + end + + describe '#uri_only_keys' do + it 'id' do + expect(subject.uri_only_keys).to include('id') + end + end + + describe '#string_only_keys' do + it 'time_mode' do + expect(subject.string_only_keys).to include('time_mode') + end + end + + describe '#legal_time_mode_values' do + it 'contains the expected values' do + expect(subject.legal_time_mode_values).to contain_exactly('trim', 'scale', 'loop') + end + end + + describe '#legal_viewing_hint_values' do + it 'contains none' do + expect(subject.legal_viewing_hint_values).to contain_exactly('none') + end + end + + describe '#initialize' do + it 'sets type to Annotation by default' do + expect(subject['type']).to eq 'Annotation' + end + it 'allows subclasses to override type' do + subclass = Class.new(described_class) do + def initialize(hsh={}) + hsh = { 'type' => 'a:SubClass' } + super(hsh) + end + end + sub = subclass.new + expect(sub['type']).to eq 'a:SubClass' + end + it 'sets motivation to painting by default' do + expect(subject['motivation']).to eq 'painting' + end + it 'allows motivation to be passed in' do + my_anno = described_class.new('motivation' => 'foo') + expect(my_anno.motivation).to eq 'foo' + end + it 'allows type to be passed in' do + my_anno = described_class.new('type' => 'bar') + expect(my_anno.type).to eq 'bar' + end end describe '#validate' do - it 'raises an error if time_mode isn\'t an allowable value' do + before(:each) do + subject['id'] = 'http://example.org/iiif/anno/1s' + subject['target'] = 'foo' + end + it 'raises IllegalValueError if id is not URI' do + exp_err_msg = "id value must be a String containing a URI for #{described_class}" + subject['id'] = 'foo' + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) + end + + it 'raises IllegalValueError if time_mode isn\'t an allowable value' do + exp_err_msg = "timeMode for #{described_class} must be one of [\"trim\", \"scale\", \"loop\"]." subject['time_mode'] = 'foo' - expect { subject.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError + expect { subject.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, exp_err_msg + end + + describe 'body is a kind of IIIF::V3::Presentation::ImageResource' do + let(:img_body_anno) { + subject['id'] = 'http://example.org/iiif/anno/1s' + subject['target'] = 'foo' + subject['body'] = img_content_resource + subject + } + it 'raises IllegalValueError if motivation isn\'t "painting"' do + exp_err_msg = "#{described_class} motivation must be 'painting' when body is a kind of IIIF::V3::Presentation::ImageResource" + img_body_anno['motivation'] = 'foo' + expect { img_body_anno.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, exp_err_msg + end + let(:img_resource_without_id) { + IIIF::V3::Presentation::ImageResource.new( + 'type' => content_type, + 'format' => mimetype + )} + let(:http_uri_err_msg) { + "when #{described_class} body is a kind of IIIF::V3::Presentation::ImageResource, ImageResource id must be an http(s) URI" + } + it 'raises IllegalValueError if no id field in ImageResource' do + img_body_anno.body = img_resource_without_id + expect { img_body_anno.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, http_uri_err_msg + end + it 'raises IllegalValueError if id in ImageResource isn\'t URI' do + img_resource_without_id['id'] = 'foo' + img_body_anno.body = img_resource_without_id + expect { img_body_anno.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, http_uri_err_msg + end + it 'raises IllegalValueError if id in ImageResource isn\'t http(s) URI' do + img_resource_without_id['id'] = 'ftp://example.com/somewhere' + img_body_anno.body = img_resource_without_id + expect { img_body_anno.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, http_uri_err_msg + end + + let(:service_context_err_msg) { + "when #{described_class} body is a kind of IIIF::V3::Presentation::ImageResource, ImageResource's service @context must include http://iiif.io/api/image/2/context.json" + } + it 'raises IllegalValueError if no @context field in ImageResource service' do + img_content_resource['service']['@context'] = nil + img_body_anno.body = img_content_resource + expect { img_body_anno.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, service_context_err_msg + end + it 'raises IllegalValueError if @context in ImageResource service doesn\'t include reference to IIIF Image API context doc' do + img_content_resource['service']['@context'] = 'http://example.com/context.json' + img_body_anno.body = img_content_resource + expect { img_body_anno.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, service_context_err_msg + img_content_resource['service']['@context'] = ['http://example.com/context.json', 'http://example.com/context2.json'] + img_body_anno.body = img_content_resource + expect { img_body_anno.validate }.to raise_error IIIF::V3::Presentation::IllegalValueError, service_context_err_msg + end + it 'does not raise error if @context in ImageResource service includes reference to IIIF Image API context doc' do + img_content_resource['service']['@context'] = ['http://example.com/context.json', IIIF::V3::Presentation::Service::IIIF_IMAGE_V2_CONTEXT] + img_body_anno.body = img_content_resource + expect { img_body_anno.validate }.not_to raise_error + end end end + + describe 'realistic examples' do + let(:anno_id) { 'http://example.org/iiif/annoation/abc666'} + let(:target_id) { 'http://example.org/iiif/canvas/abc666'} + + describe 'stanford (purl code)' do + let(:anno) { + anno = described_class.new + anno['id'] = anno_id + anno['target'] = target_id + anno.body = img_content_resource + anno + } + it 'validates' do + expect{anno.validate}.not_to raise_error + end + it 'has expected required values' do + expect(anno.id).to eq anno_id + expect(anno['type']).to eq 'Annotation' + expect(anno['motivation']).to eq 'painting' + expect(anno['target']).to eq target_id + end + it 'has expected additional values' do + expect(anno['body']).to eq img_content_resource + end + end + + describe 'from http://prezi3.iiif.io/api/presentation/3.0' do + describe 'body is image_resource with height and width' do + let(:img_type) { 'dctypes:Image' } + let(:img_mime) { 'image/jpeg' } + let(:img_h) { 2000 } + let(:img_w) { 1500 } + let(:img_hw_resource) { IIIF::V3::Presentation::ImageResource.new( + 'id' => content_id, + 'type' => img_type, + 'format' => img_mime, + 'height' => img_h, + 'width' => img_w, + 'service' => image_2_api_service + )} + let(:my_anno) { + anno = described_class.new + anno['id'] = anno_id + anno['target'] = target_id + anno.body = img_hw_resource + anno + } + it 'validates' do + expect{my_anno.validate}.not_to raise_error + end + it 'has expected additional values' do + expect(my_anno['body']).to eq img_hw_resource + expect(my_anno['body']['height']).to eq img_h + expect(my_anno['body']['width']).to eq img_w + end + + describe 'and service with height and width and tiles' do + let(:tiles_val) { [{"width" => 512, "scaleFactors" => [1,2,4,8,16]}] } + let(:service) { + s = image_2_api_service + s['height'] = 8000 + s['width'] = 6000 + s['tiles'] = tiles_val + s + } + it 'validates' do + img_hw_resource['service'] = service + expect{my_anno.validate}.not_to raise_error + end + it "body['service'] has expected additional values'" do + expect(my_anno['body']['service']).to eq service + expect(my_anno['body']['service']['height']).to eq 8000 + expect(my_anno['body']['service']['width']).to eq 6000 + expect(my_anno['body']['service']['tiles']).to eq tiles_val + end + end + end + end + + describe 'from digerati' do + describe 'anno body is audio' do + let(:body_id) { 'http://example.org/iiif/foo2.mp3' } + let(:body_type) { 'Audio' } + let(:audio_res) { IIIF::V3::Presentation::Resource.new( + 'id' => body_id, + 'type' => body_type + )} + let(:my_anno) { + anno = described_class.new + anno['id'] = anno_id + anno['target'] = target_id + anno.body = audio_res + anno + } + it 'validates' do + expect{my_anno.validate}.not_to raise_error + end + it 'has expected required values' do + expect(my_anno['type']).to eq 'Annotation' + expect(my_anno.id).to eq anno_id + expect(my_anno['motivation']).to eq 'painting' + expect(my_anno['target']).to eq target_id + end + it 'has expected additional values' do + expect(my_anno['body']).to eq audio_res + expect(my_anno['body']['type']).to eq body_type + expect(my_anno['body']['id']).to eq body_id + end + end + + describe 'anno body is video' do + let(:body_id) { 'http://example.org/foo.webm' } + let(:body_type) { 'Video' } + let(:body_mime) { 'video/webm' } + let(:video_res) { IIIF::V3::Presentation::Resource.new( + 'id' => body_id, + 'type' => body_type, + 'format' => body_mime + )} + let(:my_anno) { + anno = described_class.new + anno['id'] = anno_id + anno['target'] = target_id + anno.body = video_res + anno + } + it 'validates' do + expect{my_anno.validate}.not_to raise_error + end + it 'has expected body values' do + expect(my_anno['body']).to eq video_res + expect(my_anno['body']['type']).to eq body_type + expect(my_anno['body']['id']).to eq body_id + expect(my_anno['body']['format']).to eq body_mime + end + end + + describe 'anno body is 3d object' do + let(:body_id) { 'http://files.universalviewer.io/manifests/nelis/animal-skull/animal-skull.json' } + let(:body_type) { 'PhysicalObject' } + let(:body_mime) { 'application/vnd.threejs+json' } + let(:body_label) { 'Animal Skull' } + let(:body_res) { IIIF::V3::Presentation::Resource.new( + 'id' => body_id, + 'type' => body_type, + 'format' => body_mime, + 'label' => body_label + )} + let(:my_anno) { + anno = described_class.new + anno['id'] = anno_id + anno['target'] = target_id + anno.body = body_res + anno + } + it 'validates' do + expect{my_anno.validate}.not_to raise_error + end + it 'has expected body values' do + expect(my_anno['body']).to eq body_res + expect(my_anno['body']['type']).to eq body_type + expect(my_anno['body']['id']).to eq body_id + expect(my_anno['body']['format']).to eq body_mime + expect(my_anno['body']['label']).to eq body_label + end + end + + describe 'anno body is pdf' do + let(:body_id) { 'http://example.org/iiif/some-document.pdf' } + let(:body_type) { 'Document' } + let(:body_mime) { 'application/pdf' } + let(:body_res) { IIIF::V3::Presentation::Resource.new( + 'id' => body_id, + 'type' => body_type, + 'format' => body_mime + )} + let(:my_anno) { + anno = described_class.new + anno['id'] = anno_id + anno['target'] = target_id + anno.body = body_res + anno + } + it 'validates' do + expect{my_anno.validate}.not_to raise_error + end + it 'has expected body values' do + expect(my_anno['body']).to eq body_res + expect(my_anno['body']['type']).to eq body_type + expect(my_anno['body']['id']).to eq body_id + expect(my_anno['body']['format']).to eq body_mime + end + end + + describe 'anno body is choice (of 2 videos)' do + let(:body_type) { 'Video' } + let(:body1_id) { 'http://example.org/foo.mp4f' } + let(:body1_mime) { 'video/mp4; codec..xxxxx' } + let(:body1_res) { IIIF::V3::Presentation::Resource.new( + 'id' => body1_id, + 'type' => body_type, + 'format' => body1_mime + )} + let(:body2_id) { 'http://example.org/foo.webm' } + let(:body2_mime) { 'video/webm' } + let(:body2_res) { IIIF::V3::Presentation::Resource.new( + 'id' => body2_id, + 'type' => body_type, + 'format' => body2_mime + )} + let(:body_res) { IIIF::V3::Presentation::Choice.new( + 'items' => [body1_res, body2_res], + 'choiceHint' => 'client' + )} + let(:my_anno) { + anno = described_class.new + anno['id'] = anno_id + anno['target'] = target_id + anno.body = body_res + anno + } + it 'validates' do + expect{my_anno.validate}.not_to raise_error + end + it 'has expected body values' do + expect(my_anno['body']).to eq body_res + expect(my_anno['body'].keys.size).to eq 3 + expect(my_anno['body']['type']).to eq 'Choice' + expect(my_anno['body'].choiceHint).to eq 'client' + expect(my_anno['body']['items']).to eq [body1_res, body2_res] + end + end + end + end + end From 42ceb1fc90d3ab1264d6ff05c83d323baea78176 Mon Sep 17 00:00:00 2001 From: Naomi Dushay Date: Tue, 25 Jul 2017 19:22:21 -0700 Subject: [PATCH 4/4] v3 anno_page: improve specificity, validation and tests --- lib/iiif/v3/presentation/annotation_page.rb | 32 ++++- .../v3/presentation/annotation_page_spec.rb | 128 +++++++++++++++++- 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/lib/iiif/v3/presentation/annotation_page.rb b/lib/iiif/v3/presentation/annotation_page.rb index 22dabb2..07c29a3 100644 --- a/lib/iiif/v3/presentation/annotation_page.rb +++ b/lib/iiif/v3/presentation/annotation_page.rb @@ -3,14 +3,27 @@ module V3 module Presentation class AnnotationPage < IIIF::V3::AbstractResource - TYPE = 'AnnotationPage' + TYPE = 'AnnotationPage'.freeze def required_keys super + %w{ id } end + def prohibited_keys + super + CONTENT_RESOURCE_PROPERTIES + + %w{ first last total nav_date viewing_direction start_canvas content_annotations } + end + + def uri_only_keys + super + %w{ id } + end + def array_only_keys; - super + %w{ items }; + super + %w{ items } + end + + def legal_viewing_hint_values + super + %w{ none } end def initialize(hsh={}) @@ -20,9 +33,20 @@ def initialize(hsh={}) def validate super - # TODO: Each member or resources must be a kind of Annotation - end + unless self['id'] =~ /^https?:/ + err_msg = "id must be an http(s) URI for #{self.class}" + raise IIIF::V3::Presentation::IllegalValueError, err_msg + end + + items = self['items'] + if items && items.any? + unless items.all? { |entry| entry.instance_of?(IIIF::V3::Presentation::Annotation) } + err_msg = 'All entries in the items list must be a IIIF::V3::Presentation::Annotation' + raise IIIF::V3::Presentation::IllegalValueError, err_msg + end + end + end end end end diff --git a/spec/unit/iiif/v3/presentation/annotation_page_spec.rb b/spec/unit/iiif/v3/presentation/annotation_page_spec.rb index 6ae7f00..5df72df 100644 --- a/spec/unit/iiif/v3/presentation/annotation_page_spec.rb +++ b/spec/unit/iiif/v3/presentation/annotation_page_spec.rb @@ -1,7 +1,131 @@ describe IIIF::V3::Presentation::AnnotationPage do - describe "#{described_class}.define_methods_for_array_only_keys" do - it_behaves_like 'it has the appropriate methods for array-only keys v3' + describe '#required_keys' do + it 'id' do + expect(subject.required_keys).to include('id') + end end + describe '#prohibited_keys' do + it 'contains the expected key names' do + keys = described_class::CONTENT_RESOURCE_PROPERTIES + + %w{ + first + last + total + nav_date + viewing_direction + start_canvas + content_annotations + } + 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 + it 'items' do + expect(subject.array_only_keys).to include('items') + end + end + + describe '#legal_viewing_hint_values' do + it 'contains none' do + expect(subject.legal_viewing_hint_values).to contain_exactly('none') + end + end + + describe '#initialize' do + it 'sets type to AnnotationPage by default' do + expect(subject['type']).to eq 'AnnotationPage' + end + it 'allows subclasses to override type' do + subclass = Class.new(described_class) do + def initialize(hsh={}) + hsh = { 'type' => 'a:SubClass' } + super(hsh) + end + end + sub = subclass.new + expect(sub['type']).to eq 'a:SubClass' + end + it 'allows type to be passed in' do + ap = described_class.new('type' => 'bar') + expect(ap.type).to eq 'bar' + end + end + + describe '#validate' do + it 'raises IllegalValueError if id is not URI' do + exp_err_msg = "id value must be a String containing a URI for #{described_class}" + subject['id'] = 'foo' + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) + end + it 'raises IllegalValueError if id is not http(s)' do + subject['id'] = 'ftp://www.example.org' + exp_err_msg = "id must be an http(s) URI for #{described_class}" + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) + end + it 'raises IllegalValueError for items entry that is not an Annotation' do + subject['id'] = 'http://example.com/iiif3/annotation_page/666' + subject['items'] = [IIIF::V3::Presentation::ImageResource.new, IIIF::V3::Presentation::Annotation.new] + exp_err_msg = "All entries in the items list must be a IIIF::V3::Presentation::Annotation" + expect { subject.validate }.to raise_error(IIIF::V3::Presentation::IllegalValueError, exp_err_msg) + end + end + + describe 'realistic examples' do + let(:ap_id) { 'http://example.com/iiif3/annotation_page/666' } + let(:anno) { IIIF::V3::Presentation::Annotation.new( + 'id' => 'http://example.com/anno/666', + 'target' => 'http://example.com/canvas/abc' + )} + + describe 'stanford (purl code)' do + let(:anno_page) { + anno_page = described_class.new + anno_page['id'] = ap_id + anno_page.items << anno + anno_page + } + it 'validates' do + expect{anno_page.validate}.not_to raise_error + end + it 'has expected required values' do + expect(anno_page.id).to eq ap_id + expect(anno_page['type']).to eq 'AnnotationPage' + end + it 'has expected additional values' do + expect(anno_page.items).to eq [anno] + end + end + + describe 'two items' do + let(:anno2) { IIIF::V3::Presentation::Annotation.new( + 'id' => 'http://example.com/anno/333', + 'target' => 'http://example.com/canvas/abc' + )} + let(:anno_page) { + anno_page = described_class.new + anno_page['id'] = ap_id + anno_page.items = [anno, anno2] + anno_page + } + it 'validates' do + expect{anno_page.validate}.not_to raise_error + end + it 'has expected required values' do + expect(anno_page.id).to eq ap_id + expect(anno_page['type']).to eq 'AnnotationPage' + end + it 'has expected additional values' do + expect(anno_page.items).to eq [anno, anno2] + end + end + end end