europeana-api 0.5.2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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