api_adaptor 0.0.0 → 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30c6b5be3dca0a584c80707e773dcaf88a16588cae895628836d45298fa6bb34
4
- data.tar.gz: b394a5354f56e56d6c39c4b86e2f960ba8916578008280e7aee189b80d5bb371
3
+ metadata.gz: 15a3973e6718c891d082915a0306fec878eaadbaaad013a25dcaba91ce0cb92f
4
+ data.tar.gz: d8da61b6f522276dcd74d569673bb6d4a0dd899ab43f9cb1ad85a85eec303c28
5
5
  SHA512:
6
- metadata.gz: e48b671f2f25ac38b79afaf9b11ab06053b133f66a6df0f373070c2b101eacab5e4820833d8d3b440e8ff08bc96cde8b97c3ab70e54a21501a82776efeae9323
7
- data.tar.gz: 42c84ad34fb77d684dcf9e6f897b5865988502c95cb9959b851c02fe415382d9cee84a15606e66a017e28f37962ef500354cb4bb2c4dc3f3a24dc174ae9cb977
6
+ metadata.gz: 24586320847f202c0913f4612a246c2e9e8fef9a380090b773101a8c9fd2204811051c9551fbd7a9c76ab6e25cea655185c213735f937e7a9278b30e120fbc54
7
+ data.tar.gz: 8c884ad4796aa32f2623c07d58aa15b8727f5b8dc22549fac2e7a9e2d61849643ce9059fb56edc3cc42c0f8ae0d19389377b20083d34afdf301e72f263c2f899
data/.env.example ADDED
@@ -0,0 +1,3 @@
1
+ APP_NAME=
2
+ APP_VERSION=
3
+ APP_CONTACT=
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.0.0] - 2023-05-29
3
+ ## [0.0.1] - 2023-06-01
4
4
 
5
5
  - Initial release
6
+
7
+ ## [0.0.0] - 2023-05-29
8
+
9
+ - Bootstrapping
data/Gemfile CHANGED
@@ -4,9 +4,3 @@ source "https://rubygems.org"
4
4
 
5
5
  # Specify your gem's dependencies in api_adaptor.gemspec
6
6
  gemspec
7
-
8
- gem "rake", "~> 13.0"
9
-
10
- gem "rspec", "~> 3.0"
11
-
12
- gem "rubocop", "~> 1.21"
data/Gemfile.lock ADDED
@@ -0,0 +1,89 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ api_adaptor (0.0.0)
5
+ addressable (~> 2.8)
6
+ rest-client (~> 2.1)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.8.4)
12
+ public_suffix (>= 2.0.2, < 6.0)
13
+ ast (2.4.2)
14
+ crack (0.4.5)
15
+ rexml
16
+ diff-lcs (1.5.0)
17
+ domain_name (0.5.20190701)
18
+ unf (>= 0.0.5, < 1.0.0)
19
+ hashdiff (1.0.1)
20
+ http-accept (1.7.0)
21
+ http-cookie (1.0.5)
22
+ domain_name (~> 0.5)
23
+ json (2.6.3)
24
+ mime-types (3.4.1)
25
+ mime-types-data (~> 3.2015)
26
+ mime-types-data (3.2023.0218.1)
27
+ netrc (0.11.0)
28
+ parallel (1.23.0)
29
+ parser (3.2.2.1)
30
+ ast (~> 2.4.1)
31
+ public_suffix (5.0.1)
32
+ rainbow (3.1.1)
33
+ rake (13.0.6)
34
+ regexp_parser (2.8.0)
35
+ rest-client (2.1.0)
36
+ http-accept (>= 1.7.0, < 2.0)
37
+ http-cookie (>= 1.0.2, < 2.0)
38
+ mime-types (>= 1.16, < 4.0)
39
+ netrc (~> 0.8)
40
+ rexml (3.2.5)
41
+ rspec (3.12.0)
42
+ rspec-core (~> 3.12.0)
43
+ rspec-expectations (~> 3.12.0)
44
+ rspec-mocks (~> 3.12.0)
45
+ rspec-core (3.12.2)
46
+ rspec-support (~> 3.12.0)
47
+ rspec-expectations (3.12.3)
48
+ diff-lcs (>= 1.2.0, < 2.0)
49
+ rspec-support (~> 3.12.0)
50
+ rspec-mocks (3.12.5)
51
+ diff-lcs (>= 1.2.0, < 2.0)
52
+ rspec-support (~> 3.12.0)
53
+ rspec-support (3.12.0)
54
+ rubocop (1.51.0)
55
+ json (~> 2.3)
56
+ parallel (~> 1.10)
57
+ parser (>= 3.2.0.0)
58
+ rainbow (>= 2.2.2, < 4.0)
59
+ regexp_parser (>= 1.8, < 3.0)
60
+ rexml (>= 3.2.5, < 4.0)
61
+ rubocop-ast (>= 1.28.0, < 2.0)
62
+ ruby-progressbar (~> 1.7)
63
+ unicode-display_width (>= 2.4.0, < 3.0)
64
+ rubocop-ast (1.28.1)
65
+ parser (>= 3.2.1.0)
66
+ ruby-progressbar (1.13.0)
67
+ timecop (0.9.6)
68
+ unf (0.1.4)
69
+ unf_ext
70
+ unf_ext (0.0.8.2)
71
+ unicode-display_width (2.4.2)
72
+ webmock (3.18.1)
73
+ addressable (>= 2.8.0)
74
+ crack (>= 0.3.2)
75
+ hashdiff (>= 0.4.0, < 2.0.0)
76
+
77
+ PLATFORMS
78
+ x86_64-linux
79
+
80
+ DEPENDENCIES
81
+ api_adaptor!
82
+ rake (~> 13.0)
83
+ rspec (~> 3.0)
84
+ rubocop (~> 1.21)
85
+ timecop (~> 0.9)
86
+ webmock (~> 3.18)
87
+
88
+ BUNDLED WITH
89
+ 2.4.13
data/README.md CHANGED
@@ -15,7 +15,78 @@ If bundler is not being used to manage dependencies, install the gem by executin
15
15
 
16
16
  ## Usage
17
17
 
18
- TODO: Write usage instructions here
18
+ Use the ApiAdaptor as a base class for your API wrapper, for example:
19
+
20
+ ```
21
+ class MyApi < ApiAdaptor::Base
22
+ def base_url
23
+ endpoint
24
+ end
25
+ end
26
+ ```
27
+
28
+ Use your new class to create a client that can make HTTP requests to JSON APIs for:
29
+
30
+ ### GET JSON
31
+
32
+ ```
33
+ client = MyApi.new
34
+ response = client.get_json("http://some.endpoint/json")
35
+ ```
36
+
37
+ ### POST JSON
38
+
39
+ ```
40
+ client = MyApi.new
41
+ response = client.post_json("http://some.endpoint/json", { "foo": "bar" })
42
+ ```
43
+
44
+ ### PUT JSON
45
+
46
+ ```
47
+ client = MyApi.new
48
+ response = client.put_json("http://some.endpoint/json", { "foo": "bar" })
49
+ ```
50
+
51
+ ### PATCH JSON
52
+
53
+ ```
54
+ client = MyApi.new
55
+ response = client.patch_json("http://some.endpoint/json", { "foo": "bar" })
56
+ ```
57
+
58
+ ### DELETE JSON
59
+
60
+ ```
61
+ client = MyApi.new
62
+ response = client.delete_json("http://some.endpoint/json", { "foo": "bar" })
63
+ ```
64
+
65
+ ### GET raw requests
66
+
67
+ you can also get a raw response from the API
68
+
69
+ ```
70
+ client = MyApi.new
71
+ response = client.get_raw("http://some.endpoint/json")
72
+ ```
73
+
74
+ ## Environment variables
75
+
76
+ User Agent is populated with a default string.
77
+ See .env.example.
78
+
79
+ For instance if you provide:
80
+ ```
81
+ APP_NAME=test_app
82
+ APP_VERSION=1.0.0
83
+ APP_CONTACT=contact@example.com
84
+ ```
85
+
86
+ User agent would read
87
+ ```
88
+ test_app/1.0.0 (contact@example.com)
89
+ ```
19
90
 
20
91
  ## Contributing
21
92
 
@@ -0,0 +1,86 @@
1
+ require_relative "json_client"
2
+ require "cgi"
3
+ require_relative "null_logger"
4
+ require_relative "list_response"
5
+
6
+ class ApiAdaptor::Base
7
+ class InvalidAPIURL < StandardError
8
+ end
9
+
10
+ extend Forwardable
11
+
12
+ def client
13
+ @client ||= create_client
14
+ end
15
+
16
+ def create_client
17
+ ApiAdaptor::JsonClient.new(options)
18
+ end
19
+
20
+ def_delegators :client,
21
+ :get_json,
22
+ :post_json,
23
+ :put_json,
24
+ :patch_json,
25
+ :delete_json,
26
+ :get_raw,
27
+ :get_raw!,
28
+ :put_multipart,
29
+ :post_multipart
30
+
31
+ attr_reader :options
32
+
33
+ class << self
34
+ attr_writer :logger
35
+ attr_accessor :default_options
36
+ end
37
+
38
+ def self.logger
39
+ @logger ||= ApiAdaptor::NullLogger.new
40
+ end
41
+
42
+ def initialize(endpoint_url, options = {})
43
+ options[:endpoint_url] = endpoint_url
44
+ raise InvalidAPIURL unless endpoint_url =~ URI::RFC3986_Parser::RFC3986_URI
45
+
46
+ base_options = { logger: ApiAdaptor::Base.logger }
47
+ default_options = base_options.merge(ApiAdaptor::Base.default_options || {})
48
+ @options = default_options.merge(options)
49
+ self.endpoint = options[:endpoint_url]
50
+ end
51
+
52
+ def url_for_slug(slug, options = {})
53
+ "#{base_url}/#{slug}.json#{query_string(options)}"
54
+ end
55
+
56
+ def get_list(url)
57
+ get_json(url) do |r|
58
+ ApiAdaptor::ListResponse.new(r, self)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ attr_accessor :endpoint
65
+
66
+ def query_string(params)
67
+ return "" if params.empty?
68
+
69
+ param_pairs = params.sort.map { |key, value|
70
+ case value
71
+ when Array
72
+ value.map do |v|
73
+ "#{CGI.escape("#{key}[]")}=#{CGI.escape(v.to_s)}"
74
+ end
75
+ else
76
+ "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
77
+ end
78
+ }.flatten
79
+
80
+ "?#{param_pairs.join('&')}"
81
+ end
82
+
83
+ def uri_encode(param)
84
+ Addressable::URI.encode(param.to_s)
85
+ end
86
+ end
@@ -0,0 +1,105 @@
1
+ module ApiAdaptor
2
+ # Abstract error class
3
+ class BaseError < StandardError; end
4
+
5
+ class EndpointNotFound < BaseError; end
6
+
7
+ class TimedOutException < BaseError; end
8
+
9
+ class InvalidUrl < BaseError; end
10
+
11
+ class SocketErrorException < BaseError; end
12
+
13
+ # Superclass for all 4XX and 5XX errors
14
+ class HTTPErrorResponse < BaseError
15
+ attr_accessor :code, :error_details, :http_body
16
+
17
+ def initialize(code, message = nil, error_details = nil, http_body = nil)
18
+ super(message)
19
+ @code = code
20
+ @error_details = error_details
21
+ @http_body = http_body
22
+ end
23
+ end
24
+
25
+ # Superclass & fallback for all 4XX errors
26
+ class HTTPClientError < HTTPErrorResponse; end
27
+
28
+ class HTTPIntermittentClientError < HTTPClientError; end
29
+
30
+ class HTTPNotFound < HTTPClientError; end
31
+
32
+ class HTTPGone < HTTPClientError; end
33
+
34
+ class HTTPPayloadTooLarge < HTTPClientError; end
35
+
36
+ class HTTPUnauthorized < HTTPClientError; end
37
+
38
+ class HTTPForbidden < HTTPClientError; end
39
+
40
+ class HTTPConflict < HTTPClientError; end
41
+
42
+ class HTTPUnprocessableEntity < HTTPClientError; end
43
+
44
+ class HTTPBadRequest < HTTPClientError; end
45
+
46
+ class HTTPTooManyRequests < HTTPIntermittentClientError; end
47
+
48
+ # Superclass & fallback for all 5XX errors
49
+ class HTTPServerError < HTTPErrorResponse; end
50
+
51
+ class HTTPIntermittentServerError < HTTPServerError; end
52
+
53
+ class HTTPInternalServerError < HTTPServerError; end
54
+
55
+ class HTTPBadGateway < HTTPIntermittentServerError; end
56
+
57
+ class HTTPUnavailable < HTTPIntermittentServerError; end
58
+
59
+ class HTTPGatewayTimeout < HTTPIntermittentServerError; end
60
+
61
+ module ExceptionHandling
62
+ def build_specific_http_error(error, url, details = nil)
63
+ message = "URL: #{url}\nResponse body:\n#{error.http_body}"
64
+ code = error.http_code
65
+ error_class_for_code(code).new(code, message, details, error.http_body)
66
+ end
67
+
68
+ def error_class_for_code(code)
69
+ case code
70
+ when 400
71
+ ApiAdaptor::HTTPBadRequest
72
+ when 401
73
+ ApiAdaptor::HTTPUnauthorized
74
+ when 403
75
+ ApiAdaptor::HTTPForbidden
76
+ when 404
77
+ ApiAdaptor::HTTPNotFound
78
+ when 409
79
+ ApiAdaptor::HTTPConflict
80
+ when 410
81
+ ApiAdaptor::HTTPGone
82
+ when 413
83
+ ApiAdaptor::HTTPPayloadTooLarge
84
+ when 422
85
+ ApiAdaptor::HTTPUnprocessableEntity
86
+ when 429
87
+ ApiAdaptor::HTTPTooManyRequests
88
+ when (400..499)
89
+ ApiAdaptor::HTTPClientError
90
+ when 500
91
+ ApiAdaptor::HTTPInternalServerError
92
+ when 502
93
+ ApiAdaptor::HTTPBadGateway
94
+ when 503
95
+ ApiAdaptor::HTTPUnavailable
96
+ when 504
97
+ ApiAdaptor::HTTPGatewayTimeout
98
+ when (500..599)
99
+ ApiAdaptor::HTTPServerError
100
+ else
101
+ ApiAdaptor::HTTPErrorResponse
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,23 @@
1
+ module ApiAdaptor
2
+ class Headers
3
+ class << self
4
+ def set_header(header_name, value)
5
+ header_data[header_name] = value
6
+ end
7
+
8
+ def headers
9
+ header_data.reject { |_k, v| (v.nil? || v.empty?) }
10
+ end
11
+
12
+ def clear_headers
13
+ Thread.current[:headers] = {}
14
+ end
15
+
16
+ private
17
+
18
+ def header_data
19
+ Thread.current[:headers] ||= {}
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,202 @@
1
+ require_relative "exceptions"
2
+ require_relative "variables"
3
+ require_relative "null_logger"
4
+ require_relative "headers"
5
+ require_relative "response"
6
+
7
+ require "rest-client"
8
+
9
+ module ApiAdaptor
10
+ class JsonClient
11
+ include ApiAdaptor::ExceptionHandling
12
+
13
+ attr_accessor :logger, :options
14
+
15
+ def initialize(options = {})
16
+ if options[:disable_timeout] || options[:timeout].to_i.negative?
17
+ raise "It is no longer possible to disable the timeout."
18
+ end
19
+
20
+ @logger = options[:logger] || NullLogger.new
21
+ @options = options
22
+ end
23
+
24
+ def self.default_request_headers
25
+ {
26
+ "Accept" => "application/json",
27
+ "User-Agent" => "#{Variables.app_name}/#{Variables.app_version} (#{Variables. app_contact}",
28
+ }
29
+ end
30
+
31
+ def self.default_request_with_json_body_headers
32
+ default_request_headers.merge(json_body_headers)
33
+ end
34
+
35
+ def self.json_body_headers
36
+ {
37
+ "Content-Type" => "application/json",
38
+ }
39
+ end
40
+
41
+ DEFAULT_TIMEOUT_IN_SECONDS = 4
42
+
43
+ def get_raw!(url)
44
+ do_raw_request(:get, url)
45
+ end
46
+
47
+ def get_raw(url)
48
+ get_raw!(url)
49
+ end
50
+
51
+ def get_json(url, additional_headers = {}, &create_response)
52
+ do_json_request(:get, url, nil, additional_headers, &create_response)
53
+ end
54
+
55
+ def post_json(url, params = {}, additional_headers = {})
56
+ do_json_request(:post, url, params, additional_headers)
57
+ end
58
+
59
+ def put_json(url, params, additional_headers = {})
60
+ do_json_request(:put, url, params, additional_headers)
61
+ end
62
+
63
+ def patch_json(url, params, additional_headers = {})
64
+ do_json_request(:patch, url, params, additional_headers)
65
+ end
66
+
67
+ def delete_json(url, params = {}, additional_headers = {})
68
+ do_json_request(:delete, url, params, additional_headers)
69
+ end
70
+
71
+ def post_multipart(url, params)
72
+ r = do_raw_request(:post, url, params.merge(multipart: true))
73
+ Response.new(r)
74
+ end
75
+
76
+ def put_multipart(url, params)
77
+ r = do_raw_request(:put, url, params.merge(multipart: true))
78
+ Response.new(r)
79
+ end
80
+
81
+ private
82
+
83
+ def do_raw_request(method, url, params = nil)
84
+ do_request(method, url, params)
85
+ rescue RestClient::Exception => e
86
+ raise build_specific_http_error(e, url, nil)
87
+ end
88
+
89
+ # method: the symbolic name of the method to use, e.g. :get, :post
90
+ # url: the request URL
91
+ # params: the data to send (JSON-serialised) in the request body
92
+ # additional_headers: headers to set on the request (in addition to the default ones)
93
+ # create_response: optional block to instantiate a custom response object
94
+ # from the Net::HTTPResponse
95
+ def do_json_request(method, url, params = nil, additional_headers = {}, &create_response)
96
+ begin
97
+ if params
98
+ additional_headers.merge!(self.class.json_body_headers)
99
+ end
100
+ response = do_request(method, url, (params.to_json if params), additional_headers)
101
+ rescue RestClient::Exception => e
102
+ # Attempt to parse the body as JSON if possible
103
+ error_details = begin
104
+ e.http_body ? JSON.parse(e.http_body) : nil
105
+ rescue JSON::ParserError
106
+ nil
107
+ end
108
+ raise build_specific_http_error(e, url, error_details)
109
+ end
110
+
111
+ # If no custom response is given, just instantiate Response
112
+ create_response ||= proc { |r| Response.new(r) }
113
+ create_response.call(response)
114
+ end
115
+
116
+ # Take a hash of parameters for Request#execute; return a hash of
117
+ # parameters with authentication information included
118
+ def with_auth_options(method_params)
119
+ if @options[:bearer_token]
120
+ headers = method_params[:headers] || {}
121
+ method_params.merge(headers: headers.merge(
122
+ "Authorization" => "Bearer #{@options[:bearer_token]}",
123
+ ))
124
+ elsif @options[:basic_auth]
125
+ method_params.merge(
126
+ user: @options[:basic_auth][:user],
127
+ password: @options[:basic_auth][:password],
128
+ )
129
+ else
130
+ method_params
131
+ end
132
+ end
133
+
134
+
135
+ # Take a hash of parameters for Request#execute; return a hash of
136
+ # parameters with timeouts included
137
+ def with_timeout(method_params)
138
+ method_params.merge(
139
+ timeout: options[:timeout] || DEFAULT_TIMEOUT_IN_SECONDS,
140
+ open_timeout: options[:timeout] || DEFAULT_TIMEOUT_IN_SECONDS,
141
+ )
142
+ end
143
+
144
+ def with_headers(method_params, default_headers, additional_headers)
145
+ method_params.merge(
146
+ headers: default_headers
147
+ .merge(method_params[:headers] || {})
148
+ .merge(ApiAdaptor::Headers.headers)
149
+ .merge(additional_headers),
150
+ )
151
+ end
152
+
153
+ def with_ssl_options(method_params)
154
+ method_params.merge(
155
+ # This is the default value anyway, but we should probably be explicit
156
+ verify_ssl: OpenSSL::SSL::VERIFY_NONE,
157
+ )
158
+ end
159
+
160
+ def do_request(method, url, params = nil, additional_headers = {})
161
+ loggable = { request_uri: url, start_time: Time.now.to_f }
162
+ start_logging = loggable.merge(action: "start")
163
+ logger.debug start_logging.to_json
164
+
165
+ method_params = {
166
+ method: method,
167
+ url: url,
168
+ }
169
+
170
+ method_params[:payload] = params
171
+ method_params = with_timeout(method_params)
172
+ method_params = with_headers(method_params, self.class.default_request_headers, additional_headers)
173
+ method_params = with_auth_options(method_params)
174
+ if URI.parse(url).is_a? URI::HTTPS
175
+ method_params = with_ssl_options(method_params)
176
+ end
177
+
178
+ ::RestClient::Request.execute(method_params)
179
+ rescue Errno::ECONNREFUSED => e
180
+ logger.error loggable.merge(status: "refused", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
181
+ raise ApiAdaptor::EndpointNotFound, "Could not connect to #{url}"
182
+ rescue RestClient::Exceptions::Timeout => e
183
+ logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
184
+ raise ApiAdaptor::TimedOutException, e.message
185
+ rescue URI::InvalidURIError => e
186
+ logger.error loggable.merge(status: "invalid_uri", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
187
+ raise ApiAdaptor::InvalidUrl, e.message
188
+ rescue RestClient::Exception => e
189
+ # Log the error here, since we have access to loggable, but raise the
190
+ # exception up to the calling method to deal with
191
+ loggable.merge!(status: e.http_code, end_time: Time.now.to_f, body: e.http_body)
192
+ logger.warn loggable.to_json
193
+ raise
194
+ rescue Errno::ECONNRESET => e
195
+ logger.error loggable.merge(status: "connection_reset", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
196
+ raise ApiAdaptor::TimedOutException, e.message
197
+ rescue SocketError => e
198
+ logger.error loggable.merge(status: "socket_error", error_message: e.message, error_class: e.class.name, end_time: Time.now.to_f).to_json
199
+ raise ApiAdaptor::SocketErrorException, e.message
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,89 @@
1
+ require "json"
2
+ require "api_adaptor/response"
3
+ require "link_header"
4
+
5
+ module ApiAdaptor
6
+ # Response class for lists of multiple items.
7
+ #
8
+ # This expects responses to be in a common format, with the list of results
9
+ # contained under the `results` key. The response may also have previous and
10
+ # subsequent pages, indicated by entries in the response's `Link` header.
11
+ class ListResponse < Response
12
+ # The ListResponse is instantiated with a reference back to the API client,
13
+ # so it can make requests for the subsequent pages
14
+ def initialize(response, api_client, options = {})
15
+ super(response, options)
16
+ @api_client = api_client
17
+ end
18
+
19
+ # Pass calls to `self.each` to the `results` sub-object, so we can iterate
20
+ # over the response directly
21
+ def_delegators :results, :each, :to_ary
22
+
23
+ def results
24
+ to_hash["results"]
25
+ end
26
+
27
+ def has_next_page?
28
+ !page_link("next").nil?
29
+ end
30
+
31
+ def next_page
32
+ # This shouldn't be a performance problem, since the cache will generally
33
+ # avoid us making multiple requests for the same page, but we shouldn't
34
+ # allow the data to change once it's already been loaded, so long as we
35
+ # retain a reference to any one page in the sequence
36
+ @next_page ||= if has_next_page?
37
+ @api_client.get_list page_link("next").href
38
+ end
39
+ end
40
+
41
+ def has_previous_page?
42
+ !page_link("previous").nil?
43
+ end
44
+
45
+ def previous_page
46
+ # See the note in `next_page` for why this is memoised
47
+ @previous_page ||= if has_previous_page?
48
+ @api_client.get_list(page_link("previous").href)
49
+ end
50
+ end
51
+
52
+ # Transparently get all results across all pages. Compare this with #each
53
+ # or #results which only iterate over the current page.
54
+ #
55
+ # Example:
56
+ #
57
+ # list_response.with_subsequent_pages.each do |result|
58
+ # ...
59
+ # end
60
+ #
61
+ # or:
62
+ #
63
+ # list_response.with_subsequent_pages.count
64
+ #
65
+ # Pages of results are fetched on demand. When iterating, that means
66
+ # fetching pages as results from the current page are exhausted. If you
67
+ # invoke a method such as #count, this method will fetch all pages at that
68
+ # point. Note that the responses are stored so subsequent pages will not be
69
+ # loaded multiple times.
70
+ def with_subsequent_pages
71
+ Enumerator.new do |yielder|
72
+ each { |i| yielder << i }
73
+ if has_next_page?
74
+ next_page.with_subsequent_pages.each { |i| yielder << i }
75
+ end
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def link_header
82
+ @link_header ||= LinkHeader.parse @http_response.headers[:link]
83
+ end
84
+
85
+ def page_link(rel)
86
+ link_header.find_link(["rel", rel])
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Null logger class. This is essentially the same as sending data down the
4
+ # `/dev/null` black hole.
5
+ #
6
+ # @example Basic Usage
7
+ #
8
+ # logger = NullLogger.new
9
+ # Rails.logger = logger
10
+ #
11
+ #
12
+ # @example Basic Pattern Usage
13
+ # class SomeService
14
+ # def initialize(options = {})
15
+ # @logger = options[:logger] || NullLogger.new
16
+ # end
17
+ #
18
+ # def perform
19
+ # @logger.debug -> { "do some work here" }
20
+ # # .. ..
21
+ # @logger.info -> { "finished working" }
22
+ # end
23
+ # end
24
+ #
25
+ # service = SomeService.new(logger: Logger.new(STDOUT))
26
+ # service.perform
27
+ #
28
+ # silent = SomeService.new(logger: NullLogger.new
29
+ # silent.perform
30
+ #
31
+ module ApiAdaptor
32
+ class NullLogger
33
+ # @param _args Anything that we want to ignore
34
+ # @return [nil]
35
+ def unknown(*_args)
36
+ nil
37
+ end
38
+
39
+ # @param _args Anything that we want to ignore
40
+ # @return [nil]
41
+ def fatal(*_args)
42
+ nil
43
+ end
44
+
45
+ # @return [FALSE]
46
+ def fatal?
47
+ false
48
+ end
49
+
50
+ # @param _args Anything that we want to ignore
51
+ # @return [nil]
52
+ def error(*_args)
53
+ nil
54
+ end
55
+
56
+ # @return [FALSE]
57
+ def error?
58
+ false
59
+ end
60
+
61
+ # @param _args Anything that we want to ignore
62
+ # @return [nil]
63
+ def warn(*_args)
64
+ nil
65
+ end
66
+
67
+ # @return [FALSE]
68
+ def warn?
69
+ false
70
+ end
71
+
72
+ # @param _args Anything that we want to ignore
73
+ # @return [nil]
74
+ def info(*_args)
75
+ nil
76
+ end
77
+
78
+ # @return [FALSE]
79
+ def info?
80
+ false
81
+ end
82
+
83
+ # @param _args Anything that we want to ignore
84
+ # @return [nil]
85
+ def debug(*_args)
86
+ nil
87
+ end
88
+
89
+ # @return [FALSE]
90
+ def debug?
91
+ false
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,183 @@
1
+ require "json"
2
+ require "forwardable"
3
+
4
+ module ApiAdaptor
5
+ # This wraps an HTTP response with a JSON body.
6
+ #
7
+ # Responses can be configured to use relative URLs for `web_url` properties.
8
+ # API endpoints should return absolute URLs so that they make sense outside of the
9
+ # domain context. However on systems within an API we want to present relative URLs.
10
+ # By specifying a base URI, this will convert all matching web_urls into relative URLs
11
+ # This is useful on non-canonical frontends, such as those in staging environments.
12
+ #
13
+ # Example:
14
+ #
15
+ # r = Response.new(response, web_urls_relative_to: "https://www.gov.uk")
16
+ # r['results'][0]['web_url']
17
+ # => "/bank-holidays"
18
+ class Response
19
+ extend Forwardable
20
+ include Enumerable
21
+
22
+ class CacheControl < Hash
23
+ PATTERN = /([-a-z]+)(?:\s*=\s*([^,\s]+))?,?+/i
24
+
25
+ def initialize(value = nil)
26
+ super()
27
+ parse(value)
28
+ end
29
+
30
+ def public?
31
+ self["public"]
32
+ end
33
+
34
+ def private?
35
+ self["private"]
36
+ end
37
+
38
+ def no_cache?
39
+ self["no-cache"]
40
+ end
41
+
42
+ def no_store?
43
+ self["no-store"]
44
+ end
45
+
46
+ def must_revalidate?
47
+ self["must-revalidate"]
48
+ end
49
+
50
+ def proxy_revalidate?
51
+ self["proxy-revalidate"]
52
+ end
53
+
54
+ def max_age
55
+ self["max-age"].to_i if key?("max-age")
56
+ end
57
+
58
+ def reverse_max_age
59
+ self["r-maxage"].to_i if key?("r-maxage")
60
+ end
61
+ alias_method :r_maxage, :reverse_max_age
62
+
63
+ def shared_max_age
64
+ self["s-maxage"].to_i if key?("r-maxage")
65
+ end
66
+ alias_method :s_maxage, :shared_max_age
67
+
68
+ def to_s
69
+ directives = []
70
+ values = []
71
+
72
+ each do |key, value|
73
+ if value == true
74
+ directives << key
75
+ elsif value
76
+ values << "#{key}=#{value}"
77
+ end
78
+ end
79
+
80
+ (directives.sort + values.sort).join(", ")
81
+ end
82
+
83
+ private
84
+
85
+ def parse(header)
86
+ return if header.nil? || header.empty?
87
+
88
+ header.scan(PATTERN).each do |name, value|
89
+ self[name.downcase] = value || true
90
+ end
91
+ end
92
+ end
93
+
94
+ def_delegators :to_hash, :[], :"<=>", :each, :dig
95
+
96
+ def initialize(http_response, options = {})
97
+ @http_response = http_response
98
+ @web_urls_relative_to = options[:web_urls_relative_to] ? URI.parse(options[:web_urls_relative_to]) : nil
99
+ end
100
+
101
+ def raw_response_body
102
+ @http_response.body
103
+ end
104
+
105
+ def code
106
+ # Return an integer code for consistency with HTTPErrorResponse
107
+ @http_response.code
108
+ end
109
+
110
+ def headers
111
+ @http_response.headers
112
+ end
113
+
114
+ def expires_at
115
+ if headers[:date] && cache_control.max_age
116
+ response_date = Time.parse(headers[:date])
117
+ response_date + cache_control.max_age
118
+ elsif headers[:expires]
119
+ Time.parse(headers[:expires])
120
+ end
121
+ end
122
+
123
+ def expires_in
124
+ return unless headers[:date]
125
+
126
+ age = Time.now.utc - Time.parse(headers[:date])
127
+
128
+ if cache_control.max_age
129
+ cache_control.max_age - age.to_i
130
+ elsif headers[:expires]
131
+ Time.parse(headers[:expires]).to_i - Time.now.utc.to_i
132
+ end
133
+ end
134
+
135
+ def cache_control
136
+ @cache_control ||= CacheControl.new(headers[:cache_control])
137
+ end
138
+
139
+ def to_hash
140
+ parsed_content
141
+ end
142
+
143
+ def parsed_content
144
+ @parsed_content ||= transform_parsed(JSON.parse(@http_response.body))
145
+ end
146
+
147
+ def present?
148
+ true
149
+ end
150
+
151
+ def blank?
152
+ false
153
+ end
154
+
155
+ private
156
+
157
+ def transform_parsed(value)
158
+ return value if @web_urls_relative_to.nil?
159
+
160
+ case value
161
+ when Hash
162
+ Hash[value.map do |k, v|
163
+ # NOTE: Don't bother transforming if the value is nil
164
+ if k == "web_url" && v
165
+ # Use relative URLs to route when the web_url value is on the
166
+ # same domain as the site root. Note that we can't just use the
167
+ # `route_to` method, as this would give us technically correct
168
+ # but potentially confusing `//host/path` URLs for URLs with the
169
+ # same scheme but different hosts.
170
+ relative_url = @web_urls_relative_to.route_to(v)
171
+ [k, relative_url.host ? v : relative_url.to_s]
172
+ else
173
+ [k, transform_parsed(v)]
174
+ end
175
+ end]
176
+ when Array
177
+ value.map { |v| transform_parsed(v) }
178
+ else
179
+ value
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,15 @@
1
+ module ApiAdaptor
2
+ module Variables
3
+ def self.app_name
4
+ ENV['APP_NAME'] || "Ruby ApiAdaptor App"
5
+ end
6
+
7
+ def self.app_version
8
+ ENV['APP_VERSION'] || "Version not stated"
9
+ end
10
+
11
+ def self.app_contact
12
+ ENV['APP_CONTACT'] || "Contact not stated"
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiAdaptor
4
- VERSION = "0.0.0"
4
+ VERSION = "0.0.1"
5
5
  end
data/lib/api_adaptor.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "api_adaptor/version"
4
+ require_relative "api_adaptor/base"
4
5
 
5
6
  module ApiAdaptor
6
7
  class Error < StandardError; end
metadata CHANGED
@@ -1,15 +1,127 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api_adaptor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Huw Diprose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-05-29 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2023-06-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rest-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: addressable
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: link_header
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.0.8
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.0.8
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.18'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.18'
69
+ - !ruby/object:Gem::Dependency
70
+ name: timecop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.21'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.21'
13
125
  description: A basic adaptor to send HTTP requests and parse the responses. Intended
14
126
  to bootstrap the quick writing of Adaptors for specific APIs, without having to
15
127
  write the same old JSON request and processing time and time again.
@@ -19,15 +131,25 @@ executables: []
19
131
  extensions: []
20
132
  extra_rdoc_files: []
21
133
  files:
134
+ - ".env.example"
22
135
  - ".rspec"
23
136
  - ".rubocop.yml"
24
137
  - CHANGELOG.md
25
138
  - CODE_OF_CONDUCT.md
26
139
  - Gemfile
140
+ - Gemfile.lock
27
141
  - LICENSE.txt
28
142
  - README.md
29
143
  - Rakefile
30
144
  - lib/api_adaptor.rb
145
+ - lib/api_adaptor/base.rb
146
+ - lib/api_adaptor/exceptions.rb
147
+ - lib/api_adaptor/headers.rb
148
+ - lib/api_adaptor/json_client.rb
149
+ - lib/api_adaptor/list_response.rb
150
+ - lib/api_adaptor/null_logger.rb
151
+ - lib/api_adaptor/response.rb
152
+ - lib/api_adaptor/variables.rb
31
153
  - lib/api_adaptor/version.rb
32
154
  - sig/api_adaptor.rbs
33
155
  homepage: https://github.com/huwd/api_adaptor