europeana-api 0.5.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.travis.yml +4 -2
  4. data/Gemfile +1 -8
  5. data/README.md +103 -21
  6. data/Rakefile +2 -1
  7. data/bin/console +21 -0
  8. data/europeana-api.gemspec +12 -1
  9. data/lib/europeana/api.rb +44 -68
  10. data/lib/europeana/api/annotation.rb +20 -0
  11. data/lib/europeana/api/client.rb +51 -0
  12. data/lib/europeana/api/entity.rb +16 -0
  13. data/lib/europeana/api/errors.rb +25 -41
  14. data/lib/europeana/api/faraday_middleware.rb +26 -0
  15. data/lib/europeana/api/faraday_middleware/request/authenticated_request.rb +25 -0
  16. data/lib/europeana/api/faraday_middleware/request/parameter_repetition.rb +25 -0
  17. data/lib/europeana/api/faraday_middleware/response/handle_text.rb +19 -0
  18. data/lib/europeana/api/faraday_middleware/response/parse_json_to_various.rb +52 -0
  19. data/lib/europeana/api/logger.rb +10 -0
  20. data/lib/europeana/api/queue.rb +47 -0
  21. data/lib/europeana/api/record.rb +29 -83
  22. data/lib/europeana/api/request.rb +77 -38
  23. data/lib/europeana/api/resource.rb +30 -0
  24. data/lib/europeana/api/response.rb +47 -0
  25. data/lib/europeana/api/version.rb +2 -1
  26. data/spec/europeana/api/annotation_spec.rb +77 -0
  27. data/spec/europeana/api/client_spec.rb +46 -0
  28. data/spec/europeana/api/entity_spec.rb +6 -0
  29. data/spec/europeana/api/faraday_middleware/request/authenticated_request_spec.rb +22 -0
  30. data/spec/europeana/api/queue_spec.rb +4 -0
  31. data/spec/europeana/api/record_spec.rb +54 -104
  32. data/spec/europeana/api/request_spec.rb +3 -0
  33. data/spec/europeana/api/resource_spec.rb +47 -0
  34. data/spec/europeana/api/response_spec.rb +10 -0
  35. data/spec/europeana/api_spec.rb +34 -84
  36. data/spec/spec_helper.rb +8 -0
  37. data/spec/support/shared_examples/resource_endpoint.rb +11 -0
  38. metadata +158 -34
  39. data/lib/europeana/api/record/hierarchy.rb +0 -48
  40. data/lib/europeana/api/record/hierarchy/ancestor_self_siblings.rb +0 -12
  41. data/lib/europeana/api/record/hierarchy/base.rb +0 -30
  42. data/lib/europeana/api/record/hierarchy/children.rb +0 -12
  43. data/lib/europeana/api/record/hierarchy/following_siblings.rb +0 -12
  44. data/lib/europeana/api/record/hierarchy/parent.rb +0 -12
  45. data/lib/europeana/api/record/hierarchy/preceding_siblings.rb +0 -15
  46. data/lib/europeana/api/record/hierarchy/self.rb +0 -12
  47. data/lib/europeana/api/requestable.rb +0 -118
  48. data/lib/europeana/api/search.rb +0 -64
  49. data/lib/europeana/api/search/fields.rb +0 -112
  50. data/spec/europeana/api/errors_spec.rb +0 -23
  51. data/spec/europeana/api/record/hierarchy_spec.rb +0 -15
  52. data/spec/europeana/api/search_spec.rb +0 -97
  53. data/spec/support/shared_examples/api_request.rb +0 -65
  54. data/spec/support/shared_examples/record_request.rb +0 -26
  55. data/spec/support/shared_examples/search_request.rb +0 -42
  56. data/spec/support/webmock.rb +0 -14
@@ -1,51 +1,90 @@
1
- require 'active_support/benchmarkable'
2
- require 'net/http'
3
-
1
+ # frozen_string_literal: true
4
2
  module Europeana
5
3
  module API
6
4
  ##
7
- # Europeana API request
5
+ # An API request
8
6
  class Request
9
- include ActiveSupport::Benchmarkable
10
-
11
- # Request URI
12
- attr_reader :uri
13
-
14
- # API response
15
- attr_reader :response
16
-
17
- # @param [URI]
18
- def initialize(uri)
19
- @uri = uri
20
- end
21
-
22
- # @return (see Net::HTTP#request)
23
- def execute
24
- http = Net::HTTP.new(uri.host, uri.port)
25
- http.use_ssl = (uri.scheme == 'https')
26
- request = Net::HTTP::Get.new(uri.request_uri)
27
- retries = Europeana::API.max_retries
28
-
29
- begin
30
- attempt = Europeana::API.max_retries - retries + 1
31
- logger.info("[Europeana::API] Request URL: #{uri}")
32
- benchmark("[Europeana::API] Request query (attempt ##{attempt})", level: :info) do
33
- @response = http.request(request)
34
- end
35
- rescue Timeout::Error, Errno::ECONNREFUSED, EOFError
36
- retries -= 1
37
- raise unless retries > 0
38
- logger.warn("[Europeana::API] Network error; sleeping #{Europeana::API.retry_delay}s")
39
- sleep Europeana::API.retry_delay
40
- retry
41
- end
7
+ attr_reader :endpoint
8
+ attr_reader :params
9
+ attr_reader :api_url
10
+ attr_reader :headers
11
+ attr_reader :body
12
+ attr_writer :client
13
+
14
+ ##
15
+ # @param endpoint [Hash] endpoint options
16
+ # @param params [Hash]
17
+ def initialize(endpoint, params = {})
18
+ @endpoint = endpoint
19
+ @params = params.dup
20
+ extract_special_params
21
+ end
22
+
23
+ ##
24
+ # @return [Response]
25
+ def execute(&block)
26
+ response = Response.new(self, faraday_response(&block))
27
+ return response if client.in_parallel?
28
+
29
+ response.validate!
30
+ response.body
31
+ end
32
+
33
+ def url
34
+ build_api_url(format_params)
35
+ end
36
+
37
+ def client
38
+ @client ||= Client.new
39
+ end
40
+
41
+ protected
42
+
43
+ def extract_special_params
44
+ @api_url = @params.delete(:api_url)
45
+ @headers = @params.delete(:headers)
46
+ @body = @params.delete(:body) unless http_method == :get
47
+ end
42
48
 
43
- @response
49
+ def faraday_response
50
+ client.send(http_method) do |request|
51
+ request.url(url)
52
+ request.params = query_params
53
+ request.headers.merge!(endpoint[:headers] || {}).merge!(headers || {})
54
+ request.body = body unless http_method == :get
55
+ yield(request) if block_given?
56
+ # logger.debug("API request: #{request.inspect}")
57
+ end
44
58
  end
45
59
 
46
60
  def logger
47
61
  Europeana::API.logger
48
62
  end
63
+
64
+ def http_method
65
+ endpoint[:method] || :get
66
+ end
67
+
68
+ def format_params
69
+ params.slice(*endpoint_path_format_keys)
70
+ end
71
+
72
+ def query_params
73
+ params.except(*endpoint_path_format_keys)
74
+ end
75
+
76
+ def endpoint_path_format_keys
77
+ @endpoint_path_format_keys ||= endpoint[:path].scan(/%\{(.*?)\}/).flatten.map(&:to_sym)
78
+ end
79
+
80
+ def build_api_url(params = {})
81
+ request_path = format(endpoint[:path], params)
82
+ if api_url.nil?
83
+ request_path.sub(%r{\A/}, '') # remove leading slash for relative URLs
84
+ else
85
+ api_url + request_path
86
+ end
87
+ end
49
88
  end
50
89
  end
51
90
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ module Europeana
3
+ module API
4
+ ##
5
+ # Module for resources retrieved from the Europeana API
6
+ module Resource
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ class_attribute :api_endpoints
11
+ end
12
+
13
+ class_methods do
14
+ # @todo path is not optional; ensure that it exists
15
+ def has_api_endpoint(name, **options)
16
+ self.api_endpoints ||= {}
17
+ self.api_endpoints[name] = options
18
+
19
+ define_singleton_method(name) do |params = {}, &block|
20
+ api_request_for_endpoint(name, params).execute(&block)
21
+ end
22
+ end
23
+
24
+ def api_request_for_endpoint(name, params = {})
25
+ Request.new(self.api_endpoints[name], params)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ module Europeana
3
+ module API
4
+ class Response
5
+ attr_reader :faraday_response, :request, :error_message
6
+
7
+ delegate :body, :headers, :status, to: :faraday_response
8
+ delegate :endpoint, to: :request
9
+
10
+ # @param request [Europeana::API::Request]
11
+ # @param faraday_response [Faraday::Response]
12
+ def initialize(request, faraday_response)
13
+ @request = request
14
+ @faraday_response = faraday_response
15
+ @error_message = detect_error_message
16
+ end
17
+
18
+ def validate!
19
+ return if body.is_a?(Hash) && body[:success]
20
+ validate_endpoint_errors!
21
+ validate_generic_errors!
22
+ end
23
+
24
+ def validate_endpoint_errors!
25
+ (endpoint[:errors] || {}).each_pair do |error_pattern, exception_class|
26
+ fail exception_class.new(faraday_response), error_message if error_message =~ Regexp.new(error_pattern)
27
+ end
28
+ end
29
+
30
+ def validate_generic_errors!
31
+ fail Errors::ResourceNotFoundError.new(faraday_response), error_message if status == 404
32
+ fail Errors::MissingAPIKeyError.new(faraday_response), error_message if status == 403 && error_message =~ /No API key/
33
+ fail Errors::ClientError.new(faraday_response), error_message if (400..499).cover?(status)
34
+ fail Errors::ServerError.new(faraday_response), error_message if (500..599).cover?(status)
35
+ end
36
+
37
+ def detect_error_message
38
+ return nil unless (400..599).cover?(status)
39
+ if body.is_a?(Hash) && body.key?(:error)
40
+ body[:error]
41
+ else
42
+ Rack::Utils::HTTP_STATUS_CODES[status]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,7 +1,8 @@
1
+ # frozen_string_literal: true
1
2
  module Europeana
2
3
  ##
3
4
  # Sets the *gem* version (not the *API* version)
4
5
  module API
5
- VERSION = '0.5.2'
6
+ VERSION = '1.0.0'
6
7
  end
7
8
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+ RSpec.describe Europeana::API::Annotation do
3
+ it_behaves_like 'a resource with API endpoint', :fetch, :get
4
+ it_behaves_like 'a resource with API endpoint', :search, :get
5
+ it_behaves_like 'a resource with API endpoint', :create, :post
6
+ it_behaves_like 'a resource with API endpoint', :update, :put
7
+ it_behaves_like 'a resource with API endpoint', :delete, :delete
8
+
9
+ before(:all) do
10
+ Europeana::API.configure do |config|
11
+ config.parse_json_to = OpenStruct
12
+ end
13
+ end
14
+
15
+ after(:all) do
16
+ Europeana::API.configure do |config|
17
+ config.parse_json_to = Hash
18
+ end
19
+ end
20
+
21
+ describe '.fetch' do
22
+ before do
23
+ stub_request(:get, %r{://www.europeana.eu/api/annotations/abc/123}).
24
+ to_return(status: 200,
25
+ body: '{"@context":"https://www.w3.org/ns/anno.jsonld", "body":"Sheet music"}',
26
+ headers: { 'Content-Type' => 'application/ld+json' })
27
+ end
28
+
29
+ it 'requests an annotation from the API' do
30
+ described_class.fetch(provider: 'abc', id: '123')
31
+ expect(a_request(:get, %r{www.europeana.eu/api/annotations/abc/123})).to have_been_made.once
32
+ end
33
+
34
+ it 'returns the body of the response' do
35
+ record = described_class.fetch(provider: 'abc', id: '123')
36
+ expect(record).to be_an(OpenStruct)
37
+ expect(record).to respond_to(:body)
38
+ end
39
+ end
40
+
41
+ describe '.search' do
42
+ before do
43
+ stub_request(:get, %r{://www.europeana.eu/api/annotations/search}).
44
+ to_return(status: 200,
45
+ body: '{"@context": "https://www.w3.org/ns/anno.jsonld", "items":[]}',
46
+ headers: { 'Content-Type' => 'application/ld+json' })
47
+ end
48
+
49
+ it 'requests an annotation search from the API' do
50
+ described_class.search(query: '*:*')
51
+ expect(a_request(:get, %r{www.europeana.eu/api/annotations/search})).to have_been_made.once
52
+ end
53
+
54
+ it 'returns the body of the response' do
55
+ results = described_class.search(query: '*:*')
56
+ expect(results).to be_an(OpenStruct)
57
+ expect(results).to respond_to(:items)
58
+ end
59
+ end
60
+
61
+ describe '.delete' do
62
+ before do
63
+ stub_request(:delete, %r{://www.europeana.eu/api/annotations/abc/123}).
64
+ to_return(status: 204, body: '')
65
+ end
66
+
67
+ it 'deletes an annotation from the API' do
68
+ described_class.delete(provider: 'abc', id: '123')
69
+ expect(a_request(:delete, %r{www.europeana.eu/api/annotations/abc/123})).to have_been_made.once
70
+ end
71
+
72
+ it 'returns the empty body of the response' do
73
+ record = described_class.delete(provider: 'abc', id: '123')
74
+ expect(record).to eq('')
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ RSpec.describe Europeana::API::Client do
3
+ it { should delegate_method(:get).to(:connection) }
4
+ it { should delegate_method(:post).to(:connection) }
5
+
6
+ describe '.get' do
7
+ context 'without URL' do
8
+ it 'makes a request to the root API URL' do
9
+ stub_request(:get, Regexp.new(Europeana::API.url))
10
+ subject.get
11
+ expect(a_request(:get, Regexp.new(Europeana::API.url))).to have_been_made.once
12
+ end
13
+ end
14
+
15
+ context 'with URL' do
16
+ context 'with GET method' do
17
+ it 'sends an HTTP GET request to it' do
18
+ url = 'http://www.example.com/'
19
+ stub_request(:get, Regexp.new(url))
20
+ subject.get(url)
21
+ expect(a_request(:get, Regexp.new(url))).to have_been_made.once
22
+ end
23
+ end
24
+
25
+ context 'when request fails' do
26
+ it 'retries up to 5 times' do
27
+ url = 'http://www.example.com/'
28
+ stub_request(:get, Regexp.new(url)).to_raise(Errno::ECONNREFUSED).times(3).to_return(body: 'OK')
29
+
30
+ subject.get(url)
31
+ expect(a_request(:get, Regexp.new(url))).to have_been_made.times(4)
32
+ end
33
+ end
34
+
35
+ context 'when request times out' do
36
+ it 'does NOT retry' do
37
+ url = 'http://www.example.com/'
38
+ stub_request(:get, Regexp.new(url)).to_timeout.times(3).to_return(body: 'OK')
39
+
40
+ expect { subject.get(url) }.to raise_error(Faraday::TimeoutError)
41
+ expect(a_request(:get, Regexp.new(url))).to have_been_made.times(1)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ RSpec.describe Europeana::API::Entity do
3
+ it_behaves_like 'a resource with API endpoint', :resolve
4
+ it_behaves_like 'a resource with API endpoint', :fetch
5
+ it_behaves_like 'a resource with API endpoint', :suggest
6
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ RSpec.describe Europeana::API::FaradayMiddleware::AuthenticatedRequest do
3
+ context 'with `Europeana::API.api_key` set' do
4
+ context 'without `wskey` param' do
5
+ it 'uses `Europeana::API.api_key`'
6
+ end
7
+
8
+ context 'with `wskey` param' do
9
+ it 'uses `wskey` param'
10
+ end
11
+ end
12
+
13
+ context 'without `Europeana::API.api_key` set' do
14
+ context 'without `wskey` param' do
15
+ it 'fails'
16
+ end
17
+
18
+ context 'with `wskey` param' do
19
+ it 'uses `wskey` param'
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ RSpec.describe Europeana::API::Queue do
3
+ it 'handles parallel requests to the API'
4
+ end
@@ -1,133 +1,83 @@
1
+ # frozen_string_literal: true
1
2
  RSpec.describe Europeana::API::Record do
2
- let(:api_key) { 'xyz' }
3
- let(:record_id) { '/abc/1234' }
4
- let(:params) { { callback: 'doSomething();' } }
5
-
6
- before do
7
- Europeana::API.api_key = api_key
8
- end
9
-
10
- describe '#new' do
11
- context 'without record ID' do
12
- it 'raises error' do
13
- expect { subject }.to raise_error(ArgumentError)
14
- end
15
- end
16
-
17
- context 'with record ID' do
18
- context 'without params' do
19
- subject { described_class.new(record_id) }
20
-
21
- it 'should not raise error' do
22
- expect { subject }.to_not raise_error
23
- end
24
-
25
- it 'sets id attribute' do
26
- expect(subject.instance_variable_get(:@id)).to eq(record_id)
27
- end
28
- end
29
-
30
- context 'with params' do
31
- subject { described_class.new(record_id, params) }
32
-
33
- it 'should not raise error' do
34
- expect { subject }.to_not raise_error
35
- end
36
-
37
- it 'sets params attribute' do
38
- expect(subject.instance_variable_get(:@params)).to eq(HashWithIndifferentAccess.new(params))
39
- end
40
- end
3
+ it_behaves_like 'a resource with API endpoint', :fetch
4
+ it_behaves_like 'a resource with API endpoint', :search
5
+ it_behaves_like 'a resource with API endpoint', :self
6
+ it_behaves_like 'a resource with API endpoint', :parent
7
+ it_behaves_like 'a resource with API endpoint', :children
8
+ it_behaves_like 'a resource with API endpoint', :following_siblings
9
+ it_behaves_like 'a resource with API endpoint', :preceding_siblings
10
+ it_behaves_like 'a resource with API endpoint', :ancestor_self_siblings
11
+
12
+ before(:all) do
13
+ Europeana::API.configure do |config|
14
+ config.parse_json_to = OpenStruct
41
15
  end
42
16
  end
43
17
 
44
- describe '#id' do
45
- subject { described_class.new(record_id) }
46
- it 'gets id attribute' do
47
- expect(subject.id).to eq(subject.instance_variable_get(:@id))
18
+ after(:all) do
19
+ Europeana::API.configure do |config|
20
+ config.parse_json_to = Hash
48
21
  end
49
22
  end
50
23
 
51
- describe '#id=' do
52
- subject { described_class.new(record_id) }
53
-
54
- context 'with valid ID' do
55
- it 'sets id attribute' do
56
- subject.id = '/xyz/5678'
57
- expect(subject.instance_variable_get(:@id)).to eq('/xyz/5678')
58
- end
24
+ describe '.fetch' do
25
+ before do
26
+ stub_request(:get, %r{://www\.europeana\.eu/api/v2/record/abc/123\.json}).
27
+ to_return(status: 200, body: '{"success":true, "object":{}}', headers: { 'Content-Type' => 'application/json' })
59
28
  end
60
29
 
61
- context 'invalid ID' do
62
- it 'raises error' do
63
- expect { subject.id = 'invalid' }.to raise_error('Invalid Europeana record ID: "invalid"')
64
- end
30
+ it 'requests a record from the API' do
31
+ described_class.fetch(id: '/abc/123')
32
+ expect(a_request(:get, %r{www\.europeana\.eu/api/v2/record/abc/123\.json})).to have_been_made.once
65
33
  end
66
- end
67
34
 
68
- describe '#params' do
69
- subject { described_class.new(record_id, params) }
70
- it 'gets params attribute' do
71
- expect(subject.params).to eq(subject.instance_variable_get(:@params))
35
+ it 'returns the body of the response' do
36
+ record = described_class.fetch(id: '/abc/123')
37
+ expect(record).to be_an(OpenStruct)
38
+ expect(record).to respond_to(:object)
72
39
  end
73
40
  end
74
41
 
75
- describe '#params=' do
76
- subject { described_class.new(record_id, {}) }
42
+ describe '.search' do
43
+ before do
44
+ stub_request(:get, %r{://www\.europeana\.eu/api/v2/search\.json}).
45
+ to_return(status: 200, body: '{"success":true, "items":[]}', headers: { 'Content-Type' => 'application/json' })
46
+ end
77
47
 
78
- context 'valid params' do
79
- it 'sets params attribute' do
80
- subject.params = params
81
- expect(subject.instance_variable_get(:@params)).to eq(params)
82
- end
48
+ it 'requests a record search from the API' do
49
+ described_class.search(query: '*:*')
50
+ expect(a_request(:get, %r{www\.europeana\.eu/api/v2/search\.json\?query=*:*})).to have_been_made.once
83
51
  end
84
52
 
85
- it 'validates param names' do
86
- expect { subject.params = { invalid: 'parameter' } }.to raise_error(/Unknown key: :?invalid/)
53
+ it 'returns the body of the response' do
54
+ results = described_class.search(query: '*:*')
55
+ expect(results).to be_an(OpenStruct)
56
+ expect(results).to respond_to(:items)
87
57
  end
88
58
  end
89
59
 
90
- describe '#params_with_authentication' do
91
- subject { described_class.new(record_id, params) }
92
-
93
- context 'with API key' do
94
- it 'adds API key to params' do
95
- expect(subject.params_with_authentication).to eq(HashWithIndifferentAccess.new(params).merge(wskey: api_key))
60
+ %w(self parent children preceding_siblings following_siblings ancestor_self_siblings).each do |endpoint|
61
+ describe ".#{endpoint}" do
62
+ before do
63
+ stub_request(:get, %r{://www\.europeana\.eu/api/v2/record/abc/123/#{endpoint.to_s.dasherize}\.json}).
64
+ to_return(status: 200, body: %({"success":true, "#{endpoint.to_s.dasherize}":[]}), headers: { 'Content-Type' => 'application/json' })
96
65
  end
97
- end
98
66
 
99
- context 'without API key' do
100
- it 'raises an error' do
101
- Europeana::API.api_key = nil
102
- expect { subject.params_with_authentication }.to raise_error(Europeana::API::Errors::MissingAPIKeyError)
67
+ it "requests a record's #{endpoint.to_s.humanize.downcase} from the API" do
68
+ described_class.send(endpoint, id: '/abc/123')
69
+ expect(a_request(:get, %r{www\.europeana\.eu/api/v2/record/abc/123/#{endpoint.to_s.dasherize}\.json})).to have_been_made.once
103
70
  end
104
- end
105
- end
106
-
107
- describe '#request_uri' do
108
- subject { described_class.new(record_id, params) }
109
71
 
110
- it 'returns a URI' do
111
- expect(subject.request_uri).to be_a(URI)
112
- end
113
-
114
- it 'includes the record ID' do
115
- expect(subject.request_uri.to_s).to include(record_id)
116
- end
117
-
118
- it 'includes the query params' do
119
- expect(subject.request_uri.to_s).to include(params.to_query)
72
+ it 'returns the body of the response' do
73
+ results = described_class.send(endpoint, id: '/abc/123')
74
+ expect(results).to be_an(OpenStruct)
75
+ expect(results).to respond_to(endpoint)
76
+ end
120
77
  end
121
78
  end
122
79
 
123
- describe '#get' do
124
- subject { described_class.new(record_id, params).get }
125
- it_behaves_like 'record request'
126
- end
127
-
128
- describe '#hierarchy' do
129
- let(:record) { described_class.new(record_id, params) }
130
- subject { record.hierarchy }
131
- it { is_expected.to be_a(Europeana::API::Record::Hierarchy) }
80
+ describe '.escape' do
81
+ it 'escapes Lucene characters'
132
82
  end
133
83
  end