api_adaptor 0.0.2 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d434d1287d28256b67543919c32dba816a5c6a682247108a985eee72ac7ca86c
4
- data.tar.gz: e386558347941960b7c54bbd0107381202bfcf4c8318c0ac3908cf2bd3e1c99a
3
+ metadata.gz: ec2d6e2bc7c54d07e88bee9945a772f952076ba24b3f1fdf176e951bae3f8dad
4
+ data.tar.gz: c0d9d319cd02061d462eaba8bdc8cfd5a8dda94df5973e7ba8ef78fc3dada248
5
5
  SHA512:
6
- metadata.gz: 3ee873a96fa726a95caf41861a683abc276571b3a6424ae44f7ede1c0cc27e242de879b7f8f73930f10c0347d60ff6d31595ba55235a5be1a39e36208359e524
7
- data.tar.gz: 0a42e0ded563c8f1b9cab2433c8f3f5ca5160da8df923deb80382ecb3736012fa0a136d53799b4681011f6723c18eceaf5fd9e3e2ee853071ca4d38a9903b1ba
6
+ metadata.gz: 7ac1bd16754ce9ae420ad5ba9d3fc9d92ad280f896afcb5312688b880b228c179ca75a8edb6a16811064f6c3d71805045aae22312137b325b067a0339c801acf
7
+ data.tar.gz: 9dd42bfacca8f947ac4977c3f1a4970db143118dd101c851b61ecae8a4462cde109f4157e542020e57f147f01cc83973c11de3a8e9d0093dd7991241b1669485
data/.rubocop.yml CHANGED
@@ -1,5 +1,7 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.6
2
+ TargetRubyVersion: 3.2
3
+ NewCops: disable
4
+ SuggestExtensions: false
3
5
 
4
6
  Style/StringLiterals:
5
7
  Enabled: true
@@ -10,13 +12,10 @@ Style/StringLiteralsInInterpolation:
10
12
  EnforcedStyle: double_quotes
11
13
 
12
14
  Layout/LineLength:
13
- Max: 120
15
+ Enabled: false
14
16
 
15
17
  Metrics:
16
18
  Enabled: false
17
19
 
18
20
  Style/Documentation:
19
21
  Enabled: false
20
-
21
- Layout/LineLength:
22
- Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,4 +1,17 @@
1
- ## [Unreleased]
1
+ ## [0.1.0] - 2025-31-01
2
+
3
+ - Improvements to 307, 308 redirect behaviour
4
+ - Greater configuration over redirect behaviour
5
+ - Add CI/CD to Rubygems
6
+ - Test against Ruby v4.0
7
+
8
+ ## [0.0.2] - 2024-14-01
9
+
10
+ - Improvements to CI/CD
11
+ - Enable CodeQL
12
+ - Enable Dependabot
13
+ - Enable Rubocop Testing
14
+ - Enable unit testing against Ruby v3.2, v3.3, v3.4
2
15
 
3
16
  ## [0.0.1] - 2023-06-01
4
17
 
data/Gemfile.lock CHANGED
@@ -1,77 +1,96 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- api_adaptor (0.0.2)
4
+ api_adaptor (0.1.0)
5
5
  addressable (~> 2.8)
6
+ base64 (~> 0.3)
7
+ bigdecimal (>= 3.3, < 5.0)
6
8
  link_header (~> 0.0.8)
9
+ logger (>= 1.6, < 2.0)
7
10
  rest-client (~> 2.1)
8
11
 
9
12
  GEM
10
13
  remote: https://rubygems.org/
11
14
  specs:
12
- addressable (2.8.6)
13
- public_suffix (>= 2.0.2, < 6.0)
14
- ast (2.4.2)
15
- crack (0.4.5)
15
+ addressable (2.8.8)
16
+ public_suffix (>= 2.0.2, < 8.0)
17
+ ast (2.4.3)
18
+ base64 (0.3.0)
19
+ bigdecimal (4.0.1)
20
+ crack (1.0.1)
21
+ bigdecimal
16
22
  rexml
17
- diff-lcs (1.5.0)
23
+ diff-lcs (1.6.2)
24
+ docile (1.4.1)
18
25
  domain_name (0.6.20240107)
19
- hashdiff (1.1.0)
26
+ hashdiff (1.2.1)
20
27
  http-accept (1.7.0)
21
28
  http-cookie (1.0.5)
22
29
  domain_name (~> 0.5)
23
- json (2.7.1)
24
- language_server-protocol (3.17.0.3)
30
+ json (2.18.0)
31
+ language_server-protocol (3.17.0.5)
25
32
  link_header (0.0.8)
33
+ lint_roller (1.1.0)
34
+ logger (1.7.0)
26
35
  mime-types (3.5.2)
27
36
  mime-types-data (~> 3.2015)
28
37
  mime-types-data (3.2023.1205)
29
38
  netrc (0.11.0)
30
- parallel (1.24.0)
31
- parser (3.3.0.3)
39
+ parallel (1.27.0)
40
+ parser (3.3.10.1)
32
41
  ast (~> 2.4.1)
33
42
  racc
34
- public_suffix (5.0.4)
35
- racc (1.7.3)
43
+ prism (1.9.0)
44
+ public_suffix (7.0.2)
45
+ racc (1.8.1)
36
46
  rainbow (3.1.1)
37
- rake (13.1.0)
38
- regexp_parser (2.9.0)
47
+ rake (13.3.1)
48
+ regexp_parser (2.11.3)
39
49
  rest-client (2.1.0)
40
50
  http-accept (>= 1.7.0, < 2.0)
41
51
  http-cookie (>= 1.0.2, < 2.0)
42
52
  mime-types (>= 1.16, < 4.0)
43
53
  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)
54
+ rexml (3.4.4)
55
+ rspec (3.13.2)
56
+ rspec-core (~> 3.13.0)
57
+ rspec-expectations (~> 3.13.0)
58
+ rspec-mocks (~> 3.13.0)
59
+ rspec-core (3.13.6)
60
+ rspec-support (~> 3.13.0)
61
+ rspec-expectations (3.13.5)
52
62
  diff-lcs (>= 1.2.0, < 2.0)
53
- rspec-support (~> 3.12.0)
54
- rspec-mocks (3.12.6)
63
+ rspec-support (~> 3.13.0)
64
+ rspec-mocks (3.13.7)
55
65
  diff-lcs (>= 1.2.0, < 2.0)
56
- rspec-support (~> 3.12.0)
57
- rspec-support (3.12.1)
58
- rubocop (1.59.0)
66
+ rspec-support (~> 3.13.0)
67
+ rspec-support (3.13.6)
68
+ rubocop (1.84.0)
59
69
  json (~> 2.3)
60
- language_server-protocol (>= 3.17.0)
70
+ language_server-protocol (~> 3.17.0.2)
71
+ lint_roller (~> 1.1.0)
61
72
  parallel (~> 1.10)
62
- parser (>= 3.2.2.4)
73
+ parser (>= 3.3.0.2)
63
74
  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)
75
+ regexp_parser (>= 2.9.3, < 3.0)
76
+ rubocop-ast (>= 1.49.0, < 2.0)
67
77
  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)
78
+ unicode-display_width (>= 2.4.0, < 4.0)
79
+ rubocop-ast (1.49.0)
80
+ parser (>= 3.3.7.2)
81
+ prism (~> 1.7)
71
82
  ruby-progressbar (1.13.0)
72
- timecop (0.9.8)
73
- unicode-display_width (2.5.0)
74
- webmock (3.19.1)
83
+ simplecov (0.22.0)
84
+ docile (~> 1.1)
85
+ simplecov-html (~> 0.11)
86
+ simplecov_json_formatter (~> 0.1)
87
+ simplecov-html (0.13.1)
88
+ simplecov_json_formatter (0.1.4)
89
+ timecop (0.9.10)
90
+ unicode-display_width (3.2.0)
91
+ unicode-emoji (~> 4.1)
92
+ unicode-emoji (4.2.0)
93
+ webmock (3.26.1)
75
94
  addressable (>= 2.8.0)
76
95
  crack (>= 0.3.2)
77
96
  hashdiff (>= 0.4.0, < 2.0.0)
@@ -84,6 +103,7 @@ DEPENDENCIES
84
103
  rake (~> 13.0)
85
104
  rspec (~> 3.0)
86
105
  rubocop (~> 1.21)
106
+ simplecov (~> 0.22)
87
107
  timecop (~> 0.9)
88
108
  webmock (~> 3.18)
89
109
 
data/README.md CHANGED
@@ -17,64 +17,166 @@ If bundler is not being used to manage dependencies, install the gem by executin
17
17
  gem install api_adaptor
18
18
  ```
19
19
 
20
+ ## Releasing
21
+
22
+ Publishing is handled by GitHub Actions when you push a version tag.
23
+
24
+ - RubyGems publishing uses **Trusted Publishing (OIDC)** via `rubygems/release-gem`
25
+ - Ensure `api_adaptor` is configured on RubyGems.org with this repository/workflow as a trusted publisher.
26
+ - Bump `ApiAdaptor::VERSION` in `lib/api_adaptor/version.rb`.
27
+ - Tag the release as `vX.Y.Z` (must match `ApiAdaptor::VERSION`) and push the tag:
28
+
29
+ ```shell
30
+ git tag v0.1.1
31
+ git push origin v0.1.1
32
+ ```
33
+
20
34
  ## Usage
21
35
 
22
36
  Use the ApiAdaptor as a base class for your API wrapper, for example:
23
37
 
24
38
  ```ruby
25
- class MyApi < ApiAdaptor::Base
26
- def base_url
27
- endpoint
28
- end
29
- end
39
+ class MyApi < ApiAdaptor::Base; end
30
40
  ```
31
41
 
32
42
  Use your new class to create a client that can make HTTP requests to JSON APIs for:
33
43
 
34
- ### GET JSON
35
-
36
44
  ```ruby
37
45
  client = MyApi.new
38
- response = client.get_json("http://some.endpoint/json")
46
+ client.get_json("http://some.endpoint/json")
47
+ client.post_json("http://some.endpoint/json", { "foo": "bar" })
48
+ client.put_json("http://some.endpoint/json", { "foo": "bar" })
49
+ client.patch_json("http://some.endpoint/json", { "foo": "bar" })
50
+ client.delete_json("http://some.endpoint/json", { "foo": "bar" })
39
51
  ```
40
52
 
41
- ### POST JSON
53
+ You can also get a raw response from the API
42
54
 
43
55
  ```ruby
44
- client = MyApi.new
45
- response = client.post_json("http://some.endpoint/json", { "foo": "bar" })
56
+ client.get_raw("http://some.endpoint/json")
46
57
  ```
47
58
 
48
- ### PUT JSON
59
+ ### Redirects (3xx)
60
+
61
+ Some APIs return a `3xx` response (commonly `307`/`308`) with a `Location` header that points to the “real” URL.
62
+ `ApiAdaptor::JsonClient` follows these redirects in a controlled way so callers consistently receive a final JSON response.
63
+
64
+ #### Defaults
65
+
66
+ - Redirects are followed for `GET`/`HEAD` when the status is `301`, `302`, `303`, `307`, or `308`.
67
+ - `max_redirects` defaults to `3`.
68
+ - Cross-origin redirects (scheme/host/port change) are allowed by default, but credentials are **not** forwarded.
69
+ - Non-GET requests (`POST`, `PUT`, `PATCH`, `DELETE`) do **not** follow redirects by default.
70
+
71
+ #### Configuration
72
+
73
+ You can configure redirect behaviour by passing options into your `ApiAdaptor::Base` subclass (they are forwarded to `ApiAdaptor::JsonClient`):
49
74
 
50
75
  ```ruby
51
- client = MyApi.new
52
- response = client.put_json("http://some.endpoint/json", { "foo": "bar" })
76
+ client = MyApi.new(
77
+ "https://example.com",
78
+ max_redirects: 3,
79
+ allow_cross_origin_redirects: true,
80
+ forward_auth_on_cross_origin_redirects: false,
81
+ follow_non_get_redirects: false
82
+ )
83
+
84
+ client.get_json("https://example.com/some/endpoint.json")
53
85
  ```
54
86
 
55
- ### PATCH JSON
87
+ Or, if you are using `ApiAdaptor::JsonClient` directly:
56
88
 
57
89
  ```ruby
58
- client = MyApi.new
59
- response = client.patch_json("http://some.endpoint/json", { "foo": "bar" })
90
+ client = ApiAdaptor::JsonClient.new(
91
+ bearer_token: "SOME_BEARER_TOKEN",
92
+ max_redirects: 3,
93
+ allow_cross_origin_redirects: true,
94
+ forward_auth_on_cross_origin_redirects: false
95
+ )
96
+
97
+ client.get_json("https://example.com/some/endpoint.json")
98
+ ```
99
+
100
+ #### Security: auth and cross-origin redirects
101
+
102
+ Following redirects across origins can accidentally leak credentials (for example `Authorization` bearer tokens) to an unexpected host.
103
+ To reduce risk:
104
+
105
+ - By default, when the redirect target is cross-origin, `Authorization` is stripped and basic auth is not applied.
106
+ - To completely prevent cross-origin redirects, set `allow_cross_origin_redirects: false`.
107
+ - Only set `forward_auth_on_cross_origin_redirects: true` if you fully trust the redirect target.
108
+
109
+ #### Non-GET redirects (risk of replay)
110
+
111
+ Redirects for non-GET requests are risky because they may cause a request to be replayed (and potentially create duplicate side effects).
112
+ For that reason, redirect-following is disabled for non-GET requests by default.
113
+
114
+ If you do want to follow `307`/`308` for non-GET requests, you can opt in:
115
+
116
+ ```ruby
117
+ client = MyApi.new(
118
+ "https://example.com",
119
+ follow_non_get_redirects: true
120
+ )
121
+
122
+ client.post_json("https://example.com/some/endpoint.json", { "a" => 1 })
60
123
  ```
61
124
 
62
- ### DELETE JSON
125
+ #### Handling redirect failures
126
+
127
+ You can rescue these redirect-specific exceptions:
128
+
129
+ - `ApiAdaptor::TooManyRedirects` (exceeded `max_redirects`)
130
+ - `ApiAdaptor::RedirectLocationMissing` (a redirect response without a usable `Location`)
131
+
132
+ Example:
63
133
 
64
134
  ```ruby
65
- client = MyApi.new
66
- response = client.delete_json("http://some.endpoint/json", { "foo": "bar" })
135
+ begin
136
+ client.get_json("https://example.com/some/endpoint.json")
137
+ rescue ApiAdaptor::TooManyRedirects, ApiAdaptor::RedirectLocationMissing => e
138
+ # handle / log / retry / surface a friendly message
139
+ raise e
140
+ end
67
141
  ```
68
142
 
69
- ### GET raw requests
143
+ ### Conventional usage
70
144
 
71
- you can also get a raw response from the API
145
+ An example of how to use this repository to bootstrap an API can be found in the [WikiData REST adaptor](https://github.com/huwd/wikidata_adaptor) it was built for.
146
+
147
+ A REST API module can be created with:
72
148
 
73
149
  ```ruby
74
- client = MyApi.new
75
- response = client.get_raw("http://some.endpoint/json")
150
+ module MyApiAdaptor
151
+ # Wikidata REST API class
152
+ class RestApi < ApiAdaptor::Base
153
+ def get_foo(foo_id)
154
+ get_json("#{endpoint}/foo/#{CGI.escape(foo_id)}")
155
+ end
156
+ end
157
+ end
76
158
  ```
77
159
 
160
+ and can be wrapped in a top level module:
161
+
162
+ ```ruby
163
+ module MyApiAdaptor
164
+ class Error < StandardError; end
165
+
166
+ def self.rest_endpoint
167
+ ENV["MYAPI_REST_ENDPOINT"] || "https://example.com"
168
+ end
169
+
170
+ def self.rest_api
171
+ MyApiAdaptor::RestApi.new(rest_endpoint)
172
+ end
173
+ end
174
+ ```
175
+
176
+ The intended convention is to have test helpers ship alongside the actual Adaptor code.
177
+ See [WikiData examples here](https://github.com/huwd/wikidata_adaptor/blob/main/lib/wikidata_adaptor/test_helpers/rest_api.rb).
178
+ This allows other applications that integrate the API Adaptor to easily mock out calls and receive representative data back.
179
+
78
180
  ## Environment variables
79
181
 
80
182
  User Agent is populated with a default string.
@@ -0,0 +1,3 @@
1
+ {
2
+ "foo": "bar"
3
+ }
@@ -42,9 +42,9 @@ module ApiAdaptor
42
42
  @logger ||= ApiAdaptor::NullLogger.new
43
43
  end
44
44
 
45
- def initialize(endpoint_url, options = {})
45
+ def initialize(endpoint_url = nil, options = {})
46
46
  options[:endpoint_url] = endpoint_url
47
- raise InvalidAPIURL unless endpoint_url =~ URI::RFC3986_Parser::RFC3986_URI
47
+ raise InvalidAPIURL if !endpoint_url.nil? && endpoint_url !~ URI::RFC3986_Parser::RFC3986_URI
48
48
 
49
49
  base_options = { logger: ApiAdaptor::Base.logger }
50
50
  default_options = base_options.merge(ApiAdaptor::Base.default_options || {})
@@ -4,6 +4,10 @@ module ApiAdaptor
4
4
  # Abstract error class
5
5
  class BaseError < StandardError; end
6
6
 
7
+ class TooManyRedirects < BaseError; end
8
+
9
+ class RedirectLocationMissing < BaseError; end
10
+
7
11
  class EndpointNotFound < BaseError; end
8
12
 
9
13
  class TimedOutException < BaseError; end
@@ -43,6 +47,8 @@ module ApiAdaptor
43
47
 
44
48
  class HTTPUnprocessableEntity < HTTPClientError; end
45
49
 
50
+ class HTTPUnprocessableContent < HTTPClientError; end
51
+
46
52
  class HTTPBadRequest < HTTPClientError; end
47
53
 
48
54
  class HTTPTooManyRequests < HTTPIntermittentClientError; end
@@ -39,6 +39,7 @@ module ApiAdaptor
39
39
  end
40
40
 
41
41
  DEFAULT_TIMEOUT_IN_SECONDS = 4
42
+ DEFAULT_MAX_REDIRECTS = 3
42
43
 
43
44
  def get_raw!(url)
44
45
  do_raw_request(:get, url)
@@ -154,49 +155,173 @@ module ApiAdaptor
154
155
  )
155
156
  end
156
157
 
158
+ def max_redirects
159
+ value = options.fetch(:max_redirects, DEFAULT_MAX_REDIRECTS)
160
+ value = value.to_i
161
+ value.negative? ? 0 : value
162
+ end
163
+
164
+ def follow_non_get_redirects?
165
+ options.fetch(:follow_non_get_redirects, false)
166
+ end
167
+
168
+ def allow_cross_origin_redirects?
169
+ options.fetch(:allow_cross_origin_redirects, true)
170
+ end
171
+
172
+ def forward_auth_on_cross_origin_redirects?
173
+ options.fetch(:forward_auth_on_cross_origin_redirects, false)
174
+ end
175
+
176
+ def redirect_status_code?(code)
177
+ code.to_i >= 300 && code.to_i <= 399
178
+ end
179
+
180
+ def follow_redirect_code?(method, code)
181
+ code = code.to_i
182
+ return false unless redirect_status_code?(code)
183
+ return false if code == 304
184
+ return false if [305, 306].include?(code)
185
+
186
+ if %i[get head].include?(method)
187
+ [301, 302, 303, 307, 308].include?(code)
188
+ else
189
+ return true if follow_non_get_redirects? && [307, 308].include?(code)
190
+
191
+ false
192
+ end
193
+ end
194
+
195
+ def response_location(response)
196
+ return nil unless response
197
+
198
+ headers = response.headers || {}
199
+ headers[:location] || headers["location"] || headers["Location"]
200
+ end
201
+
202
+ def resolve_location(current_url, location)
203
+ URI.join(current_url, location.to_s).to_s
204
+ rescue URI::Error
205
+ location.to_s
206
+ end
207
+
208
+ def origin_for(url)
209
+ uri = URI.parse(url)
210
+ [uri.scheme, uri.host, uri.port]
211
+ end
212
+
157
213
  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
214
+ current_method = method
215
+ current_url = url
216
+ current_params = params
217
+ redirects_followed = 0
161
218
 
162
- method_params = {
163
- method: method,
164
- url: url
165
- }
219
+ initial_origin = begin
220
+ origin_for(url)
221
+ rescue URI::InvalidURIError => e
222
+ raise ApiAdaptor::InvalidUrl, e.message
223
+ end
166
224
 
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
225
+ loop do
226
+ loggable = { request_uri: current_url, start_time: Time.now.to_f }
227
+ start_logging = loggable.merge(action: "start")
228
+ logger.debug start_logging.to_json
229
+
230
+ method_params = {
231
+ method: current_method,
232
+ url: current_url,
233
+ max_redirects: 0
234
+ }
235
+ method_params[:payload] = current_params
236
+ method_params = with_timeout(method_params)
237
+ method_params = with_headers(method_params, self.class.default_request_headers, additional_headers)
238
+
239
+ begin
240
+ current_origin = origin_for(current_url)
241
+ cross_origin = current_origin != initial_origin
242
+ include_auth = !cross_origin || forward_auth_on_cross_origin_redirects?
243
+ method_params = with_auth_options(method_params) if include_auth
244
+ unless include_auth
245
+ if method_params[:headers]
246
+ method_params[:headers].delete("Authorization")
247
+ method_params[:headers].delete("Proxy-Authorization")
248
+ end
249
+ method_params.delete(:user)
250
+ method_params.delete(:password)
251
+ end
252
+
253
+ method_params = with_ssl_options(method_params) if URI.parse(current_url).is_a? URI::HTTPS
254
+ return ::RestClient::Request.execute(method_params)
255
+ rescue RestClient::ExceptionWithResponse => e
256
+ if e.is_a?(RestClient::Exceptions::Timeout)
257
+ logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name,
258
+ end_time: Time.now.to_f).to_json
259
+ raise ApiAdaptor::TimedOutException, e.message
260
+ end
261
+
262
+ status_code = (e.http_code || e.response&.code).to_i
263
+
264
+ raise ApiAdaptor::TimedOutException, e.message if status_code == 408
265
+
266
+ if follow_redirect_code?(current_method.to_sym, status_code)
267
+ location = response_location(e.response)
268
+ raise ApiAdaptor::RedirectLocationMissing, "Redirect response missing Location header for #{current_url}" if location.to_s.strip.empty?
269
+
270
+ next_url = resolve_location(current_url, location)
271
+ begin
272
+ next_origin = origin_for(next_url)
273
+ rescue URI::InvalidURIError => e
274
+ logger.error loggable.merge(status: "invalid_uri", error_message: e.message, error_class: e.class.name,
275
+ end_time: Time.now.to_f).to_json
276
+ raise ApiAdaptor::InvalidUrl, e.message
277
+ end
278
+ if next_origin != initial_origin && !allow_cross_origin_redirects?
279
+ loggable.merge!(status: status_code, end_time: Time.now.to_f, body: e.http_body)
280
+ logger.warn loggable.to_json
281
+ raise
282
+ end
283
+
284
+ raise ApiAdaptor::TooManyRedirects, "Too many redirects (max #{max_redirects}) while requesting #{url}" if redirects_followed >= max_redirects
285
+
286
+ redirects_followed += 1
287
+ current_url = next_url
288
+
289
+ next
290
+ end
291
+
292
+ loggable.merge!(status: status_code, end_time: Time.now.to_f, body: e.http_body)
293
+ logger.warn loggable.to_json
294
+ raise
295
+ rescue Errno::ECONNREFUSED => e
296
+ logger.error loggable.merge(status: "refused", error_message: e.message, error_class: e.class.name,
297
+ end_time: Time.now.to_f).to_json
298
+ raise ApiAdaptor::EndpointNotFound, "Could not connect to #{current_url}"
299
+ rescue Timeout::Error => e
300
+ logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name,
301
+ end_time: Time.now.to_f).to_json
302
+ raise ApiAdaptor::TimedOutException, e.message
303
+ rescue RestClient::Exceptions::Timeout => e
304
+ logger.error loggable.merge(status: "timeout", error_message: e.message, error_class: e.class.name,
305
+ end_time: Time.now.to_f).to_json
306
+ raise ApiAdaptor::TimedOutException, e.message
307
+ rescue URI::InvalidURIError => e
308
+ logger.error loggable.merge(status: "invalid_uri", error_message: e.message, error_class: e.class.name,
309
+ end_time: Time.now.to_f).to_json
310
+ raise ApiAdaptor::InvalidUrl, e.message
311
+ rescue RestClient::Exception => e
312
+ loggable.merge!(status: e.http_code, end_time: Time.now.to_f, body: e.http_body)
313
+ logger.warn loggable.to_json
314
+ raise
315
+ rescue Errno::ECONNRESET => e
316
+ logger.error loggable.merge(status: "connection_reset", error_message: e.message, error_class: e.class.name,
317
+ end_time: Time.now.to_f).to_json
318
+ raise ApiAdaptor::TimedOutException, e.message
319
+ rescue SocketError => e
320
+ logger.error loggable.merge(status: "socket_error", error_message: e.message, error_class: e.class.name,
321
+ end_time: Time.now.to_f).to_json
322
+ raise ApiAdaptor::SocketErrorException, e.message
323
+ end
324
+ end
200
325
  end
201
326
  end
202
327
  end
@@ -10,19 +10,18 @@ module ApiAdaptor
10
10
  # API endpoints should return absolute URLs so that they make sense outside of the
11
11
  # domain context. However on systems within an API we want to present relative URLs.
12
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
13
  #
15
14
  # Example:
16
15
  #
17
- # r = Response.new(response, web_urls_relative_to: "https://www.gov.uk")
16
+ # r = Response.new(response, web_urls_relative_to: "https://www.example.com")
18
17
  # r['results'][0]['web_url']
19
- # => "/bank-holidays"
18
+ # => "/foo"
20
19
  class Response
21
20
  extend Forwardable
22
21
  include Enumerable
23
22
 
24
23
  class CacheControl < Hash
25
- PATTERN = /([-a-z]+)(?:\s*=\s*([^,\s]+))?,?+/i.freeze
24
+ PATTERN = /([-a-z]+)(?:\s*=\s*([^,\s]+))?,?+/i
26
25
 
27
26
  def initialize(value = nil)
28
27
  super()
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiAdaptor
4
- VERSION = "0.0.2"
4
+ VERSION = "0.1.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api_adaptor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Huw Diprose
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-01-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: addressable
@@ -24,6 +23,40 @@ dependencies:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
25
  version: '2.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bigdecimal
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.3'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '5.0'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.3'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '5.0'
27
60
  - !ruby/object:Gem::Dependency
28
61
  name: link_header
29
62
  requirement: !ruby/object:Gem::Requirement
@@ -38,6 +71,26 @@ dependencies:
38
71
  - - "~>"
39
72
  - !ruby/object:Gem::Version
40
73
  version: 0.0.8
74
+ - !ruby/object:Gem::Dependency
75
+ name: logger
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '1.6'
81
+ - - "<"
82
+ - !ruby/object:Gem::Version
83
+ version: '2.0'
84
+ type: :runtime
85
+ prerelease: false
86
+ version_requirements: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '1.6'
91
+ - - "<"
92
+ - !ruby/object:Gem::Version
93
+ version: '2.0'
41
94
  - !ruby/object:Gem::Dependency
42
95
  name: rest-client
43
96
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +147,20 @@ dependencies:
94
147
  - - "~>"
95
148
  - !ruby/object:Gem::Version
96
149
  version: '1.21'
150
+ - !ruby/object:Gem::Dependency
151
+ name: simplecov
152
+ requirement: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - "~>"
155
+ - !ruby/object:Gem::Version
156
+ version: '0.22'
157
+ type: :development
158
+ prerelease: false
159
+ version_requirements: !ruby/object:Gem::Requirement
160
+ requirements:
161
+ - - "~>"
162
+ - !ruby/object:Gem::Version
163
+ version: '0.22'
97
164
  - !ruby/object:Gem::Dependency
98
165
  name: timecop
99
166
  requirement: !ruby/object:Gem::Requirement
@@ -141,6 +208,7 @@ files:
141
208
  - LICENSE.txt
142
209
  - README.md
143
210
  - Rakefile
211
+ - fixtures/v1/integration/foo.json
144
212
  - lib/api_adaptor.rb
145
213
  - lib/api_adaptor/base.rb
146
214
  - lib/api_adaptor/exceptions.rb
@@ -160,7 +228,6 @@ metadata:
160
228
  homepage_uri: https://github.com/huwd/api_adaptor
161
229
  source_code_uri: https://github.com/huwd/api_adaptor
162
230
  changelog_uri: https://github.com/huwd/api_adaptor/blob/main/CHANGELOG.md
163
- post_install_message:
164
231
  rdoc_options: []
165
232
  require_paths:
166
233
  - lib
@@ -168,15 +235,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
168
235
  requirements:
169
236
  - - ">="
170
237
  - !ruby/object:Gem::Version
171
- version: 2.6.0
238
+ version: 3.2.0
172
239
  required_rubygems_version: !ruby/object:Gem::Requirement
173
240
  requirements:
174
241
  - - ">="
175
242
  - !ruby/object:Gem::Version
176
243
  version: '0'
177
244
  requirements: []
178
- rubygems_version: 3.4.13
179
- signing_key:
245
+ rubygems_version: 4.0.3
180
246
  specification_version: 4
181
247
  summary: A basic adaptor to send HTTP requests and parse the responses.
182
248
  test_files: []