api_adaptor 0.0.0 → 0.0.2

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