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
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ require 'europeana/api/faraday_middleware'
3
+ require 'typhoeus/adapters/faraday'
4
+
5
+ module Europeana
6
+ module API
7
+ ##
8
+ # The API client responsible for handling requests and responses
9
+ class Client
10
+ attr_reader :queue
11
+
12
+ delegate :get, :post, :put, :delete, :head, :patch, :options, to: :connection
13
+
14
+ def initialize
15
+ @queue = Queue.new(self)
16
+ end
17
+
18
+ def in_parallel?
19
+ @queue.present?
20
+ end
21
+
22
+ ##
23
+ # `Faraday` connection to the API
24
+ #
25
+ # * Requests are retried 5 times at an interval of 3 seconds
26
+ # * Requests are instrumented
27
+ # * JSON responses are parsed
28
+ #
29
+ # @return [Faraday::Connection]
30
+ def connection
31
+ @connection ||= begin
32
+ Faraday.new do |conn|
33
+ conn.request :instrumentation
34
+ conn.request :parameter_repetition
35
+ conn.request :authenticated_request
36
+ conn.request :retry, max: 5, interval: 3, exceptions: [Errno::ECONNREFUSED, EOFError]
37
+
38
+ conn.options.open_timeout = 5
39
+ conn.options.timeout = 45
40
+
41
+ conn.response :json_various, content_type: /\bjson$/
42
+ conn.response :text, content_type: %r{^text(\b[^/]+)?/(plain|html)$}
43
+
44
+ conn.adapter :typhoeus
45
+ conn.url_prefix = Europeana::API.url
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ module Europeana
3
+ module API
4
+ ##
5
+ # Interface to the Entity API
6
+ #
7
+ # @see http://entity.europeana.eu/docs/
8
+ class Entity
9
+ include Resource
10
+
11
+ has_api_endpoint :resolve, path: '/entities/resolve'
12
+ has_api_endpoint :fetch, path: '/entities/%{type}/%{namespace}/%{identifier}'
13
+ has_api_endpoint :suggest, path: '/entities/suggest'
14
+ end
15
+ end
16
+ end
@@ -1,60 +1,44 @@
1
+ # frozen_string_literal: true
1
2
  module Europeana
2
3
  module API
3
4
  module Errors
4
- ##
5
- # Raised if API requests are attempted without the API key having been set.
6
- #
7
- # @todo Use one-line error messages (in backwards-incompatible version)
8
- class MissingAPIKeyError < StandardError
9
- def initialize(msg = nil)
10
- msg ||= <<-MSG
11
- Missing API key.
12
-
13
- The Europeana API key has not been set.
14
-
15
- Sign up for an API key at: http://labs.europeana.eu/api/registration/
16
-
17
- Set the key with:
5
+ class Base < StandardError
6
+ attr_reader :faraday_response
18
7
 
19
- Europeana::API.api_key = "xyz"
20
- MSG
21
- super(msg)
8
+ def initialize(faraday_response)
9
+ @faraday_response = faraday_response
22
10
  end
23
11
  end
24
12
 
13
+ ##
14
+ # Raised if API requests are attempted without the API key having been set.
15
+ class MissingAPIKeyError < Base
16
+ end
17
+
25
18
  ##
26
19
  # Raised if the API response success flag is false, indicating a problem
27
20
  # with the request.
28
- class RequestError < StandardError
29
- def initialize(msg = nil)
30
- msg ||= <<-MSG
31
- Request error.
32
-
33
- There was a problem with your request to the Europeana API.
34
- MSG
35
- super(msg)
36
- end
21
+ class RequestError < Base
22
+ end
23
+
24
+ class ResourceNotFoundError < Base
25
+ end
26
+
27
+ class ClientError < Base
28
+ end
29
+
30
+ class ServerError < Base
37
31
  end
38
32
 
39
33
  ##
40
34
  # Raised if the API response is not valid JSON.
41
- class ResponseError < StandardError
42
- def initialize(msg = nil)
43
- msg ||= <<-MSG
44
- Response error.
45
-
46
- Unable to parse the response from the Europeana API.
47
- MSG
48
- super(msg)
49
- end
35
+ class ResponseError < Base
50
36
  end
51
37
 
52
- module Request
53
- ##
54
- # Raised if the API response indicates invalid pagination params in
55
- # the request.
56
- class PaginationError < StandardError
57
- end
38
+ ##
39
+ # Raised if the API response indicates invalid pagination params in
40
+ # the request.
41
+ class PaginationError < Base
58
42
  end
59
43
  end
60
44
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday_middleware'
5
+
6
+ module Europeana
7
+ module API
8
+ ##
9
+ # `Faraday` middleware for Europeana API specific behaviours
10
+ module FaradayMiddleware
11
+ autoload :AuthenticatedRequest, 'europeana/api/faraday_middleware/request/authenticated_request'
12
+ autoload :ParameterRepetition, 'europeana/api/faraday_middleware/request/parameter_repetition'
13
+
14
+ autoload :HandleText, 'europeana/api/faraday_middleware/response/handle_text'
15
+ autoload :ParseJsonToVarious, 'europeana/api/faraday_middleware/response/parse_json_to_various'
16
+
17
+ Faraday::Request.register_middleware \
18
+ authenticated_request: -> { AuthenticatedRequest },
19
+ parameter_repetition: -> { ParameterRepetition }
20
+
21
+ Faraday::Response.register_middleware \
22
+ text: -> { HandleText },
23
+ json_various: -> { ParseJsonToVarious }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ require 'rack'
3
+
4
+ module Europeana
5
+ module API
6
+ module FaradayMiddleware
7
+ ##
8
+ # `Faraday` middleware to handle Europeana API authentication
9
+ class AuthenticatedRequest < Faraday::Middleware
10
+ def call(env)
11
+ ensure_api_key(env)
12
+ @app.call env
13
+ end
14
+
15
+ def ensure_api_key(env)
16
+ query = Rack::Utils.parse_query(env.url.query)
17
+ return if query.key?('wskey')
18
+
19
+ query['wskey'] = Europeana::API.key
20
+ env.url.query = Rack::Utils.build_query(query)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ module Europeana
3
+ module API
4
+ module FaradayMiddleware
5
+ ##
6
+ # Handles multiple URL params with the same name
7
+ #
8
+ # Europeana's APIs expect params with the same name to appear in URL
9
+ # queries like `?qf=this&qf=that`, but Faraday constructs them the
10
+ # Rack/Rails way, like `?qf[]=this&qf[]=that`. This middleware constructs
11
+ # them to the former format, using `Rack::Utils.build_query`.
12
+ class ParameterRepetition < Faraday::Middleware
13
+ def call(env)
14
+ repeat_query_parameters(env)
15
+ @app.call env
16
+ end
17
+
18
+ def repeat_query_parameters(env)
19
+ query = Rack::Utils.parse_nested_query(env.url.query)
20
+ env.url.query = Rack::Utils.build_query(query)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ require 'faraday_middleware/response_middleware'
3
+
4
+ module Europeana
5
+ module API
6
+ module FaradayMiddleware
7
+ ##
8
+ # Handles plain text & HTML responses from the API, which are never desired
9
+ class HandleText < ::FaradayMiddleware::ResponseMiddleware
10
+ def process_response(env)
11
+ super
12
+ content_type = env.response_headers['Content-Type']
13
+ fail Europeana::API::Errors::ResponseError.new(env),
14
+ %(API responded with Content-Type "#{content_type}" and status #{env[:status]})
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require 'faraday_middleware/response_middleware'
3
+ require 'active_support/json'
4
+
5
+ module Europeana
6
+ module API
7
+ module FaradayMiddleware
8
+ ##
9
+ # Handles JSON parsing of API responses
10
+ #
11
+ # Returns the response as either a `Hash` (default) or an `OpenStruct`.
12
+ #
13
+ # To set the response format to be `OpenStruct`:
14
+ # ```ruby
15
+ # Europeana::API.configure do |config|
16
+ # config.parse_json_to = OpenStruct
17
+ # end
18
+ # ```
19
+ #
20
+ # If using `OpenStruct`, changes "-" in JSON field keys to "_", so that
21
+ # they become methods.
22
+ class ParseJsonToVarious < ::FaradayMiddleware::ResponseMiddleware
23
+ dependency do
24
+ require 'json' unless defined?(JSON)
25
+ end
26
+
27
+ define_parser do |body|
28
+ unless body.strip.empty?
29
+ hash = JSON.parse(body)
30
+ if Europeana::API.configuration.parse_json_to == OpenStruct
31
+ underscored_hash = underscore_hash_keys(hash)
32
+ underscored_body = underscored_hash.to_json
33
+ JSON.parse(underscored_body, object_class: OpenStruct)
34
+ else
35
+ hash.with_indifferent_access
36
+ end
37
+ end
38
+ end
39
+
40
+ class << self
41
+ def underscore_hash_keys(hash)
42
+ hash.keys.each do |k|
43
+ hash[k] = underscore_hash_keys(hash[k]) if hash[k].is_a?(Hash)
44
+ hash[k.underscore] = hash.delete(k) if k =~ /-/
45
+ end
46
+ hash
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require 'active_support/notifications'
3
+
4
+ # Subscribe to Faraday request instrumentation
5
+ ActiveSupport::Notifications.subscribe('request.faraday') do |_name, starts, ends, _id, env|
6
+ url = env[:url]
7
+ http_method = env[:method].to_s.upcase
8
+ duration = ends - starts
9
+ Europeana::API.logger.info(format('%s %s (%.3f s)', http_method, url, duration))
10
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ module Europeana
3
+ module API
4
+ ##
5
+ # A queue of API requests to run in parallel
6
+ class Queue
7
+ Item = Struct.new(:resource, :method, :params)
8
+
9
+ attr_reader :client
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ @items = []
14
+ end
15
+
16
+ def present?
17
+ @items.present?
18
+ end
19
+
20
+ def add(resource, method, **params)
21
+ @items << Item.new(resource, method, **params)
22
+ end
23
+
24
+ def run
25
+ responses = []
26
+
27
+ client.connection.in_parallel do
28
+ @items.each do |item|
29
+ resource_class = Europeana::API.send(item.resource)
30
+ request = resource_class.send(:api_request_for_endpoint, item.method, item.params)
31
+ request.client = client
32
+ responses << request.execute
33
+ end
34
+ end
35
+
36
+ responses.map do |response|
37
+ begin
38
+ response.validate!
39
+ response.body
40
+ rescue StandardError => exception
41
+ exception
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Europeana
2
3
  module API
3
4
  ##
@@ -5,93 +6,38 @@ module Europeana
5
6
  #
6
7
  # @see http://labs.europeana.eu/api/record/
7
8
  class Record
8
- autoload :Hierarchy, 'europeana/api/record/hierarchy'
9
-
10
- include Requestable
11
-
12
- # Europeana ID of the record
13
- attr_reader :id
14
-
15
- # Request parameters to send to the API
16
- attr_reader :params
17
-
18
- ##
19
- # @param [String] id Europeana ID of the record
20
- # @param [Hash] params Request parameters
21
- def initialize(id, params = {})
22
- self.request_params = params
23
- @id = id
24
- end
25
-
26
- ##
27
- # Sets record ID attribute after validating format.
28
- #
29
- # @param [String] id Record ID
30
- #
31
- def id=(id)
32
- unless id.is_a?(String) && id.match(%r{\A/[^/]+/[^/]+\B})
33
- fail ArgumentError, "Invalid Europeana record ID: \"#{id}\""
34
- end
35
- @id = id
36
- end
37
-
38
- ##
39
- # Sets request parameters after validating keys
40
- #
41
- # Valid parameter keys:
42
- # * :callback
43
- #
44
- # For explanations of these request parameters, see: http://labs.europeana.eu/api/record/
45
- #
46
- # @param (see #initialize)
47
- # @return [Hash] Request parameters
48
- def params=(params = {})
49
- params.assert_valid_keys(:callback, :wskey)
50
- @params = params
51
- end
52
-
53
- ##
54
- # Gets the URL for this Record request
55
- #
56
- # @param [Hash{Symbol => Object}] options
57
- # @option options [Boolean] :ld (false)
58
- # Request JSON-LD
59
- # @return [String]
60
- def request_url(options = {})
61
- options.assert_valid_keys(:ld)
62
- (api_url + "/record#{@id}.json").tap do |url|
63
- url << 'ld' if options[:ld]
64
- end
9
+ include Resource
10
+
11
+ has_api_endpoint :search,
12
+ path: '/v2/search.json',
13
+ errors: {
14
+ 'Invalid query parameter' => Errors::RequestError,
15
+ /1000 search results/ => Errors::PaginationError
16
+ }
17
+ has_api_endpoint :fetch, path: '/v2/record%{id}.json'
18
+
19
+ # Hierarchies
20
+ %w(self parent children preceding_siblings following_siblings ancestor_self_siblings).each do |hierarchical|
21
+ has_api_endpoint hierarchical.to_sym, path: "/v2/record%{id}/#{hierarchical.dasherize}.json"
65
22
  end
66
23
 
67
- alias_method :get, :execute_request
68
-
69
- ##
70
- # Examines the `success` and `error` fields of the response for failure
71
- #
72
- # @raise [Europeana::Errors::RequestError] if API response has
73
- # `success:false`
74
- # @raise [Europeana::Errors::RequestError] if API response has 404 status
75
- # code
76
- # @see Requestable#parse_response
77
- def parse_response(response, options = {})
78
- super.tap do |body|
79
- if (options[:ld] && !(200..299).include?(response.code.to_i)) || (!options[:ld] && !body[:success])
80
- fail Errors::RequestError, (body.key?(:error) ? body[:error] : response.code)
24
+ class << self
25
+ ##
26
+ # Escapes Lucene syntax special characters for use in query parameters.
27
+ #
28
+ # The `Europeana::API` gem does not perform this escaping itself.
29
+ # Applications using the gem are responsible for escaping parameters
30
+ # when needed.
31
+ #
32
+ # @param [String] text Text to escape
33
+ # @return [String] Escaped text
34
+ def escape(text)
35
+ fail ArgumentError, "Expected String, got #{text.class}" unless text.is_a?(String)
36
+ specials = %w<\\ + - & | ! ( ) { } [ ] ^ " ~ * ? : />
37
+ specials.each_with_object(text.dup) do |char, unescaped|
38
+ unescaped.gsub!(char, '\\\\' + char) # prepends *one* backslash
81
39
  end
82
40
  end
83
- rescue JSON::ParserError
84
- if response.code.to_i == 404
85
- # Handle HTML 404 responses on malformed record ID, emulating API's
86
- # JSON response.
87
- raise Errors::RequestError, "Invalid record identifier: #{@id}"
88
- else
89
- raise
90
- end
91
- end
92
-
93
- def hierarchy
94
- @hierarchy ||= Hierarchy.new(id)
95
41
  end
96
42
  end
97
43
  end