api_adaptor 0.0.1 → 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 +4 -4
- data/.rubocop.yml +10 -2
- data/CHANGELOG.md +14 -1
- data/Gemfile.lock +65 -43
- data/README.md +143 -35
- data/fixtures/v1/integration/foo.json +3 -0
- data/lib/api_adaptor/base.rb +67 -63
- data/lib/api_adaptor/exceptions.rb +9 -1
- data/lib/api_adaptor/headers.rb +5 -3
- data/lib/api_adaptor/json_client.rb +176 -51
- data/lib/api_adaptor/list_response.rb +8 -12
- data/lib/api_adaptor/response.rb +8 -7
- data/lib/api_adaptor/variables.rb +14 -12
- data/lib/api_adaptor/version.rb +1 -1
- metadata +89 -23
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec2d6e2bc7c54d07e88bee9945a772f952076ba24b3f1fdf176e951bae3f8dad
|
|
4
|
+
data.tar.gz: c0d9d319cd02061d462eaba8bdc8cfd5a8dda94df5973e7ba8ef78fc3dada248
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7ac1bd16754ce9ae420ad5ba9d3fc9d92ad280f896afcb5312688b880b228c179ca75a8edb6a16811064f6c3d71805045aae22312137b325b067a0339c801acf
|
|
7
|
+
data.tar.gz: 9dd42bfacca8f947ac4977c3f1a4970db143118dd101c851b61ecae8a4462cde109f4157e542020e57f147f01cc83973c11de3a8e9d0093dd7991241b1669485
|
data/.rubocop.yml
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
AllCops:
|
|
2
|
-
TargetRubyVersion: 2
|
|
2
|
+
TargetRubyVersion: 3.2
|
|
3
|
+
NewCops: disable
|
|
4
|
+
SuggestExtensions: false
|
|
3
5
|
|
|
4
6
|
Style/StringLiterals:
|
|
5
7
|
Enabled: true
|
|
@@ -10,4 +12,10 @@ Style/StringLiteralsInInterpolation:
|
|
|
10
12
|
EnforcedStyle: double_quotes
|
|
11
13
|
|
|
12
14
|
Layout/LineLength:
|
|
13
|
-
|
|
15
|
+
Enabled: false
|
|
16
|
+
|
|
17
|
+
Metrics:
|
|
18
|
+
Enabled: false
|
|
19
|
+
|
|
20
|
+
Style/Documentation:
|
|
21
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
## [
|
|
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,75 +1,96 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
api_adaptor (0.
|
|
4
|
+
api_adaptor (0.1.0)
|
|
5
5
|
addressable (~> 2.8)
|
|
6
|
+
base64 (~> 0.3)
|
|
7
|
+
bigdecimal (>= 3.3, < 5.0)
|
|
8
|
+
link_header (~> 0.0.8)
|
|
9
|
+
logger (>= 1.6, < 2.0)
|
|
6
10
|
rest-client (~> 2.1)
|
|
7
11
|
|
|
8
12
|
GEM
|
|
9
13
|
remote: https://rubygems.org/
|
|
10
14
|
specs:
|
|
11
|
-
addressable (2.8.
|
|
12
|
-
public_suffix (>= 2.0.2, <
|
|
13
|
-
ast (2.4.
|
|
14
|
-
|
|
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
|
|
15
22
|
rexml
|
|
16
|
-
diff-lcs (1.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
hashdiff (1.
|
|
23
|
+
diff-lcs (1.6.2)
|
|
24
|
+
docile (1.4.1)
|
|
25
|
+
domain_name (0.6.20240107)
|
|
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.
|
|
24
|
-
|
|
30
|
+
json (2.18.0)
|
|
31
|
+
language_server-protocol (3.17.0.5)
|
|
32
|
+
link_header (0.0.8)
|
|
33
|
+
lint_roller (1.1.0)
|
|
34
|
+
logger (1.7.0)
|
|
35
|
+
mime-types (3.5.2)
|
|
25
36
|
mime-types-data (~> 3.2015)
|
|
26
|
-
mime-types-data (3.2023.
|
|
37
|
+
mime-types-data (3.2023.1205)
|
|
27
38
|
netrc (0.11.0)
|
|
28
|
-
parallel (1.
|
|
29
|
-
parser (3.
|
|
39
|
+
parallel (1.27.0)
|
|
40
|
+
parser (3.3.10.1)
|
|
30
41
|
ast (~> 2.4.1)
|
|
31
|
-
|
|
42
|
+
racc
|
|
43
|
+
prism (1.9.0)
|
|
44
|
+
public_suffix (7.0.2)
|
|
45
|
+
racc (1.8.1)
|
|
32
46
|
rainbow (3.1.1)
|
|
33
|
-
rake (13.
|
|
34
|
-
regexp_parser (2.
|
|
47
|
+
rake (13.3.1)
|
|
48
|
+
regexp_parser (2.11.3)
|
|
35
49
|
rest-client (2.1.0)
|
|
36
50
|
http-accept (>= 1.7.0, < 2.0)
|
|
37
51
|
http-cookie (>= 1.0.2, < 2.0)
|
|
38
52
|
mime-types (>= 1.16, < 4.0)
|
|
39
53
|
netrc (~> 0.8)
|
|
40
|
-
rexml (3.
|
|
41
|
-
rspec (3.
|
|
42
|
-
rspec-core (~> 3.
|
|
43
|
-
rspec-expectations (~> 3.
|
|
44
|
-
rspec-mocks (~> 3.
|
|
45
|
-
rspec-core (3.
|
|
46
|
-
rspec-support (~> 3.
|
|
47
|
-
rspec-expectations (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)
|
|
48
62
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
49
|
-
rspec-support (~> 3.
|
|
50
|
-
rspec-mocks (3.
|
|
63
|
+
rspec-support (~> 3.13.0)
|
|
64
|
+
rspec-mocks (3.13.7)
|
|
51
65
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
52
|
-
rspec-support (~> 3.
|
|
53
|
-
rspec-support (3.
|
|
54
|
-
rubocop (1.
|
|
66
|
+
rspec-support (~> 3.13.0)
|
|
67
|
+
rspec-support (3.13.6)
|
|
68
|
+
rubocop (1.84.0)
|
|
55
69
|
json (~> 2.3)
|
|
70
|
+
language_server-protocol (~> 3.17.0.2)
|
|
71
|
+
lint_roller (~> 1.1.0)
|
|
56
72
|
parallel (~> 1.10)
|
|
57
|
-
parser (>= 3.
|
|
73
|
+
parser (>= 3.3.0.2)
|
|
58
74
|
rainbow (>= 2.2.2, < 4.0)
|
|
59
|
-
regexp_parser (>=
|
|
60
|
-
|
|
61
|
-
rubocop-ast (>= 1.28.0, < 2.0)
|
|
75
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
76
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
62
77
|
ruby-progressbar (~> 1.7)
|
|
63
|
-
unicode-display_width (>= 2.4.0, <
|
|
64
|
-
rubocop-ast (1.
|
|
65
|
-
parser (>= 3.
|
|
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)
|
|
66
82
|
ruby-progressbar (1.13.0)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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)
|
|
73
94
|
addressable (>= 2.8.0)
|
|
74
95
|
crack (>= 0.3.2)
|
|
75
96
|
hashdiff (>= 0.4.0, < 2.0.0)
|
|
@@ -82,6 +103,7 @@ DEPENDENCIES
|
|
|
82
103
|
rake (~> 13.0)
|
|
83
104
|
rspec (~> 3.0)
|
|
84
105
|
rubocop (~> 1.21)
|
|
106
|
+
simplecov (~> 0.22)
|
|
85
107
|
timecop (~> 0.9)
|
|
86
108
|
webmock (~> 3.18)
|
|
87
109
|
|
data/README.md
CHANGED
|
@@ -7,90 +7,198 @@ 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
|
-
|
|
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
|
-
|
|
16
|
+
```shell
|
|
17
|
+
gem install api_adaptor
|
|
18
|
+
```
|
|
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
|
+
```
|
|
15
33
|
|
|
16
34
|
## Usage
|
|
17
35
|
|
|
18
36
|
Use the ApiAdaptor as a base class for your API wrapper, for example:
|
|
19
37
|
|
|
20
|
-
```
|
|
21
|
-
class MyApi < ApiAdaptor::Base
|
|
22
|
-
def base_url
|
|
23
|
-
endpoint
|
|
24
|
-
end
|
|
25
|
-
end
|
|
38
|
+
```ruby
|
|
39
|
+
class MyApi < ApiAdaptor::Base; end
|
|
26
40
|
```
|
|
27
41
|
|
|
28
42
|
Use your new class to create a client that can make HTTP requests to JSON APIs for:
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
```ruby
|
|
45
|
+
client = MyApi.new
|
|
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" })
|
|
35
51
|
```
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
You can also get a raw response from the API
|
|
38
54
|
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
response = client.post_json("http://some.endpoint/json", { "foo": "bar" })
|
|
55
|
+
```ruby
|
|
56
|
+
client.get_raw("http://some.endpoint/json")
|
|
42
57
|
```
|
|
43
58
|
|
|
44
|
-
###
|
|
59
|
+
### Redirects (3xx)
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
50
72
|
|
|
51
|
-
|
|
73
|
+
You can configure redirect behaviour by passing options into your `ApiAdaptor::Base` subclass (they are forwarded to `ApiAdaptor::JsonClient`):
|
|
52
74
|
|
|
75
|
+
```ruby
|
|
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
|
-
|
|
55
|
-
|
|
86
|
+
|
|
87
|
+
Or, if you are using `ApiAdaptor::JsonClient` directly:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
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")
|
|
56
98
|
```
|
|
57
99
|
|
|
58
|
-
|
|
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.
|
|
59
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
|
-
|
|
62
|
-
|
|
124
|
+
|
|
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:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
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
|
|
63
141
|
```
|
|
64
142
|
|
|
65
|
-
###
|
|
143
|
+
### Conventional usage
|
|
66
144
|
|
|
67
|
-
|
|
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.
|
|
68
146
|
|
|
147
|
+
A REST API module can be created with:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
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
|
|
69
158
|
```
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
72
174
|
```
|
|
73
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
|
+
|
|
74
180
|
## Environment variables
|
|
75
181
|
|
|
76
182
|
User Agent is populated with a default string.
|
|
77
183
|
See .env.example.
|
|
78
184
|
|
|
79
185
|
For instance if you provide:
|
|
80
|
-
|
|
186
|
+
|
|
187
|
+
```bash
|
|
81
188
|
APP_NAME=test_app
|
|
82
189
|
APP_VERSION=1.0.0
|
|
83
190
|
APP_CONTACT=contact@example.com
|
|
84
191
|
```
|
|
85
192
|
|
|
86
193
|
User agent would read
|
|
87
|
-
|
|
194
|
+
|
|
195
|
+
```text
|
|
88
196
|
test_app/1.0.0 (contact@example.com)
|
|
89
197
|
```
|
|
90
198
|
|
|
91
199
|
## Contributing
|
|
92
200
|
|
|
93
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/huwd/api_adaptor
|
|
201
|
+
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).
|
|
94
202
|
|
|
95
203
|
## License
|
|
96
204
|
|
data/lib/api_adaptor/base.rb
CHANGED
|
@@ -1,86 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require_relative "json_client"
|
|
2
4
|
require "cgi"
|
|
3
5
|
require_relative "null_logger"
|
|
4
6
|
require_relative "list_response"
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
class
|
|
8
|
-
|
|
8
|
+
module ApiAdaptor
|
|
9
|
+
class Base
|
|
10
|
+
class InvalidAPIURL < StandardError
|
|
11
|
+
end
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
extend Forwardable
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
def client
|
|
16
|
+
@client ||= create_client
|
|
17
|
+
end
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
def create_client
|
|
20
|
+
ApiAdaptor::JsonClient.new(options)
|
|
21
|
+
end
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
def self.logger
|
|
42
|
+
@logger ||= ApiAdaptor::NullLogger.new
|
|
43
|
+
end
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
def initialize(endpoint_url = nil, options = {})
|
|
46
|
+
options[:endpoint_url] = endpoint_url
|
|
47
|
+
raise InvalidAPIURL if !endpoint_url.nil? && endpoint_url !~ URI::RFC3986_Parser::RFC3986_URI
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
def url_for_slug(slug, options = {})
|
|
56
|
+
"#{base_url}/#{slug}.json#{query_string(options)}"
|
|
57
|
+
end
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
def get_list(url)
|
|
60
|
+
get_json(url) do |r|
|
|
61
|
+
ApiAdaptor::ListResponse.new(r, self)
|
|
62
|
+
end
|
|
59
63
|
end
|
|
60
|
-
end
|
|
61
64
|
|
|
62
|
-
private
|
|
65
|
+
private
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
attr_accessor :endpoint
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
def query_string(params)
|
|
70
|
+
return "" if params.empty?
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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)}"
|
|
74
80
|
end
|
|
75
|
-
|
|
76
|
-
"#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
|
|
77
|
-
end
|
|
78
|
-
}.flatten
|
|
81
|
+
end.flatten
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
"?#{param_pairs.join("&")}"
|
|
84
|
+
end
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
def uri_encode(param)
|
|
87
|
+
Addressable::URI.encode(param.to_s)
|
|
88
|
+
end
|
|
85
89
|
end
|
|
86
|
-
end
|
|
90
|
+
end
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module ApiAdaptor
|
|
2
4
|
# Abstract error class
|
|
3
5
|
class BaseError < StandardError; end
|
|
4
6
|
|
|
7
|
+
class TooManyRedirects < BaseError; end
|
|
8
|
+
|
|
9
|
+
class RedirectLocationMissing < BaseError; end
|
|
10
|
+
|
|
5
11
|
class EndpointNotFound < BaseError; end
|
|
6
12
|
|
|
7
13
|
class TimedOutException < BaseError; end
|
|
@@ -41,6 +47,8 @@ module ApiAdaptor
|
|
|
41
47
|
|
|
42
48
|
class HTTPUnprocessableEntity < HTTPClientError; end
|
|
43
49
|
|
|
50
|
+
class HTTPUnprocessableContent < HTTPClientError; end
|
|
51
|
+
|
|
44
52
|
class HTTPBadRequest < HTTPClientError; end
|
|
45
53
|
|
|
46
54
|
class HTTPTooManyRequests < HTTPIntermittentClientError; end
|
|
@@ -102,4 +110,4 @@ module ApiAdaptor
|
|
|
102
110
|
end
|
|
103
111
|
end
|
|
104
112
|
end
|
|
105
|
-
end
|
|
113
|
+
end
|
data/lib/api_adaptor/headers.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module ApiAdaptor
|
|
2
4
|
class Headers
|
|
3
5
|
class << self
|
|
@@ -6,18 +8,18 @@ module ApiAdaptor
|
|
|
6
8
|
end
|
|
7
9
|
|
|
8
10
|
def headers
|
|
9
|
-
header_data.reject { |_k, v|
|
|
11
|
+
header_data.reject { |_k, v| v.nil? || v.empty? }
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def clear_headers
|
|
13
15
|
Thread.current[:headers] = {}
|
|
14
16
|
end
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
private
|
|
17
19
|
|
|
18
20
|
def header_data
|
|
19
21
|
Thread.current[:headers] ||= {}
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
end
|
|
23
|
-
end
|
|
25
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require_relative "exceptions"
|
|
2
4
|
require_relative "variables"
|
|
3
5
|
require_relative "null_logger"
|
|
@@ -13,9 +15,7 @@ module ApiAdaptor
|
|
|
13
15
|
attr_accessor :logger, :options
|
|
14
16
|
|
|
15
17
|
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
|
|
18
|
+
raise "It is no longer possible to disable the timeout." if options[:disable_timeout] || options[:timeout].to_i.negative?
|
|
19
19
|
|
|
20
20
|
@logger = options[:logger] || NullLogger.new
|
|
21
21
|
@options = options
|
|
@@ -24,7 +24,7 @@ module ApiAdaptor
|
|
|
24
24
|
def self.default_request_headers
|
|
25
25
|
{
|
|
26
26
|
"Accept" => "application/json",
|
|
27
|
-
"User-Agent" => "#{Variables.app_name}/#{Variables.app_version} (#{Variables.
|
|
27
|
+
"User-Agent" => "#{Variables.app_name}/#{Variables.app_version} (#{Variables.app_contact})"
|
|
28
28
|
}
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -34,11 +34,12 @@ module ApiAdaptor
|
|
|
34
34
|
|
|
35
35
|
def self.json_body_headers
|
|
36
36
|
{
|
|
37
|
-
"Content-Type" => "application/json"
|
|
37
|
+
"Content-Type" => "application/json"
|
|
38
38
|
}
|
|
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)
|
|
@@ -78,7 +79,7 @@ module ApiAdaptor
|
|
|
78
79
|
Response.new(r)
|
|
79
80
|
end
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
private
|
|
82
83
|
|
|
83
84
|
def do_raw_request(method, url, params = nil)
|
|
84
85
|
do_request(method, url, params)
|
|
@@ -94,9 +95,7 @@ module ApiAdaptor
|
|
|
94
95
|
# from the Net::HTTPResponse
|
|
95
96
|
def do_json_request(method, url, params = nil, additional_headers = {}, &create_response)
|
|
96
97
|
begin
|
|
97
|
-
if params
|
|
98
|
-
additional_headers.merge!(self.class.json_body_headers)
|
|
99
|
-
end
|
|
98
|
+
additional_headers.merge!(self.class.json_body_headers) if params
|
|
100
99
|
response = do_request(method, url, (params.to_json if params), additional_headers)
|
|
101
100
|
rescue RestClient::Exception => e
|
|
102
101
|
# Attempt to parse the body as JSON if possible
|
|
@@ -119,25 +118,24 @@ module ApiAdaptor
|
|
|
119
118
|
if @options[:bearer_token]
|
|
120
119
|
headers = method_params[:headers] || {}
|
|
121
120
|
method_params.merge(headers: headers.merge(
|
|
122
|
-
"Authorization" => "Bearer #{@options[:bearer_token]}"
|
|
121
|
+
"Authorization" => "Bearer #{@options[:bearer_token]}"
|
|
123
122
|
))
|
|
124
123
|
elsif @options[:basic_auth]
|
|
125
124
|
method_params.merge(
|
|
126
125
|
user: @options[:basic_auth][:user],
|
|
127
|
-
password: @options[:basic_auth][:password]
|
|
126
|
+
password: @options[:basic_auth][:password]
|
|
128
127
|
)
|
|
129
128
|
else
|
|
130
129
|
method_params
|
|
131
130
|
end
|
|
132
131
|
end
|
|
133
132
|
|
|
134
|
-
|
|
135
133
|
# Take a hash of parameters for Request#execute; return a hash of
|
|
136
134
|
# parameters with timeouts included
|
|
137
135
|
def with_timeout(method_params)
|
|
138
136
|
method_params.merge(
|
|
139
137
|
timeout: options[:timeout] || DEFAULT_TIMEOUT_IN_SECONDS,
|
|
140
|
-
open_timeout: options[:timeout] || DEFAULT_TIMEOUT_IN_SECONDS
|
|
138
|
+
open_timeout: options[:timeout] || DEFAULT_TIMEOUT_IN_SECONDS
|
|
141
139
|
)
|
|
142
140
|
end
|
|
143
141
|
|
|
@@ -146,57 +144,184 @@ module ApiAdaptor
|
|
|
146
144
|
headers: default_headers
|
|
147
145
|
.merge(method_params[:headers] || {})
|
|
148
146
|
.merge(ApiAdaptor::Headers.headers)
|
|
149
|
-
.merge(additional_headers)
|
|
147
|
+
.merge(additional_headers)
|
|
150
148
|
)
|
|
151
149
|
end
|
|
152
150
|
|
|
153
151
|
def with_ssl_options(method_params)
|
|
154
152
|
method_params.merge(
|
|
155
153
|
# This is the default value anyway, but we should probably be explicit
|
|
156
|
-
verify_ssl: OpenSSL::SSL::VERIFY_NONE
|
|
154
|
+
verify_ssl: OpenSSL::SSL::VERIFY_NONE
|
|
157
155
|
)
|
|
158
156
|
end
|
|
159
157
|
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
164
163
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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)
|
|
169
190
|
|
|
170
|
-
|
|
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)
|
|
191
|
+
false
|
|
176
192
|
end
|
|
193
|
+
end
|
|
177
194
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
|
|
213
|
+
def do_request(method, url, params = nil, additional_headers = {})
|
|
214
|
+
current_method = method
|
|
215
|
+
current_url = url
|
|
216
|
+
current_params = params
|
|
217
|
+
redirects_followed = 0
|
|
218
|
+
|
|
219
|
+
initial_origin = begin
|
|
220
|
+
origin_for(url)
|
|
221
|
+
rescue URI::InvalidURIError => e
|
|
222
|
+
raise ApiAdaptor::InvalidUrl, e.message
|
|
223
|
+
end
|
|
224
|
+
|
|
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
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "json"
|
|
2
4
|
require "api_adaptor/response"
|
|
3
5
|
require "link_header"
|
|
@@ -24,7 +26,7 @@ module ApiAdaptor
|
|
|
24
26
|
to_hash["results"]
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
def
|
|
29
|
+
def next_page?
|
|
28
30
|
!page_link("next").nil?
|
|
29
31
|
end
|
|
30
32
|
|
|
@@ -33,20 +35,16 @@ module ApiAdaptor
|
|
|
33
35
|
# avoid us making multiple requests for the same page, but we shouldn't
|
|
34
36
|
# allow the data to change once it's already been loaded, so long as we
|
|
35
37
|
# retain a reference to any one page in the sequence
|
|
36
|
-
@next_page ||= if
|
|
37
|
-
@api_client.get_list page_link("next").href
|
|
38
|
-
end
|
|
38
|
+
@next_page ||= (@api_client.get_list page_link("next").href if next_page?)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def
|
|
41
|
+
def previous_page?
|
|
42
42
|
!page_link("previous").nil?
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def previous_page
|
|
46
46
|
# See the note in `next_page` for why this is memoised
|
|
47
|
-
@previous_page ||= if
|
|
48
|
-
@api_client.get_list(page_link("previous").href)
|
|
49
|
-
end
|
|
47
|
+
@previous_page ||= (@api_client.get_list(page_link("previous").href) if previous_page?)
|
|
50
48
|
end
|
|
51
49
|
|
|
52
50
|
# Transparently get all results across all pages. Compare this with #each
|
|
@@ -70,13 +68,11 @@ module ApiAdaptor
|
|
|
70
68
|
def with_subsequent_pages
|
|
71
69
|
Enumerator.new do |yielder|
|
|
72
70
|
each { |i| yielder << i }
|
|
73
|
-
if
|
|
74
|
-
next_page.with_subsequent_pages.each { |i| yielder << i }
|
|
75
|
-
end
|
|
71
|
+
next_page.with_subsequent_pages.each { |i| yielder << i } if next_page?
|
|
76
72
|
end
|
|
77
73
|
end
|
|
78
74
|
|
|
79
|
-
|
|
75
|
+
private
|
|
80
76
|
|
|
81
77
|
def link_header
|
|
82
78
|
@link_header ||= LinkHeader.parse @http_response.headers[:link]
|
data/lib/api_adaptor/response.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "json"
|
|
2
4
|
require "forwardable"
|
|
3
5
|
|
|
@@ -8,13 +10,12 @@ module ApiAdaptor
|
|
|
8
10
|
# API endpoints should return absolute URLs so that they make sense outside of the
|
|
9
11
|
# domain context. However on systems within an API we want to present relative URLs.
|
|
10
12
|
# 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
|
#
|
|
13
14
|
# Example:
|
|
14
15
|
#
|
|
15
|
-
# r = Response.new(response, web_urls_relative_to: "https://www.
|
|
16
|
+
# r = Response.new(response, web_urls_relative_to: "https://www.example.com")
|
|
16
17
|
# r['results'][0]['web_url']
|
|
17
|
-
# => "/
|
|
18
|
+
# => "/foo"
|
|
18
19
|
class Response
|
|
19
20
|
extend Forwardable
|
|
20
21
|
include Enumerable
|
|
@@ -58,12 +59,12 @@ module ApiAdaptor
|
|
|
58
59
|
def reverse_max_age
|
|
59
60
|
self["r-maxage"].to_i if key?("r-maxage")
|
|
60
61
|
end
|
|
61
|
-
|
|
62
|
+
alias r_maxage reverse_max_age
|
|
62
63
|
|
|
63
64
|
def shared_max_age
|
|
64
65
|
self["s-maxage"].to_i if key?("r-maxage")
|
|
65
66
|
end
|
|
66
|
-
|
|
67
|
+
alias s_maxage shared_max_age
|
|
67
68
|
|
|
68
69
|
def to_s
|
|
69
70
|
directives = []
|
|
@@ -152,7 +153,7 @@ module ApiAdaptor
|
|
|
152
153
|
false
|
|
153
154
|
end
|
|
154
155
|
|
|
155
|
-
|
|
156
|
+
private
|
|
156
157
|
|
|
157
158
|
def transform_parsed(value)
|
|
158
159
|
return value if @web_urls_relative_to.nil?
|
|
@@ -180,4 +181,4 @@ module ApiAdaptor
|
|
|
180
181
|
end
|
|
181
182
|
end
|
|
182
183
|
end
|
|
183
|
-
end
|
|
184
|
+
end
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
module Variables
|
|
3
|
-
def self.app_name
|
|
4
|
-
ENV['APP_NAME'] || "Ruby ApiAdaptor App"
|
|
5
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
6
2
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
end
|
|
13
|
+
def self.app_contact
|
|
14
|
+
ENV["APP_CONTACT"] || "Contact not stated"
|
|
14
15
|
end
|
|
15
|
-
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/api_adaptor/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,43 +1,62 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: api_adaptor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
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:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
13
|
+
name: addressable
|
|
15
14
|
requirement: !ruby/object:Gem::Requirement
|
|
16
15
|
requirements:
|
|
17
16
|
- - "~>"
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '2.
|
|
18
|
+
version: '2.8'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - "~>"
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '2.
|
|
25
|
+
version: '2.8'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
27
|
+
name: base64
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
30
29
|
requirements:
|
|
31
30
|
- - "~>"
|
|
32
31
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
32
|
+
version: '0.3'
|
|
34
33
|
type: :runtime
|
|
35
34
|
prerelease: false
|
|
36
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
36
|
requirements:
|
|
38
37
|
- - "~>"
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
|
-
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'
|
|
41
60
|
- !ruby/object:Gem::Dependency
|
|
42
61
|
name: link_header
|
|
43
62
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -53,33 +72,39 @@ dependencies:
|
|
|
53
72
|
- !ruby/object:Gem::Version
|
|
54
73
|
version: 0.0.8
|
|
55
74
|
- !ruby/object:Gem::Dependency
|
|
56
|
-
name:
|
|
75
|
+
name: logger
|
|
57
76
|
requirement: !ruby/object:Gem::Requirement
|
|
58
77
|
requirements:
|
|
59
|
-
- - "
|
|
78
|
+
- - ">="
|
|
60
79
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '
|
|
62
|
-
|
|
80
|
+
version: '1.6'
|
|
81
|
+
- - "<"
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '2.0'
|
|
84
|
+
type: :runtime
|
|
63
85
|
prerelease: false
|
|
64
86
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
87
|
requirements:
|
|
66
|
-
- - "
|
|
88
|
+
- - ">="
|
|
67
89
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: '
|
|
90
|
+
version: '1.6'
|
|
91
|
+
- - "<"
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '2.0'
|
|
69
94
|
- !ruby/object:Gem::Dependency
|
|
70
|
-
name:
|
|
95
|
+
name: rest-client
|
|
71
96
|
requirement: !ruby/object:Gem::Requirement
|
|
72
97
|
requirements:
|
|
73
98
|
- - "~>"
|
|
74
99
|
- !ruby/object:Gem::Version
|
|
75
|
-
version: '
|
|
76
|
-
type: :
|
|
100
|
+
version: '2.1'
|
|
101
|
+
type: :runtime
|
|
77
102
|
prerelease: false
|
|
78
103
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
104
|
requirements:
|
|
80
105
|
- - "~>"
|
|
81
106
|
- !ruby/object:Gem::Version
|
|
82
|
-
version: '
|
|
107
|
+
version: '2.1'
|
|
83
108
|
- !ruby/object:Gem::Dependency
|
|
84
109
|
name: rake
|
|
85
110
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -122,6 +147,48 @@ dependencies:
|
|
|
122
147
|
- - "~>"
|
|
123
148
|
- !ruby/object:Gem::Version
|
|
124
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'
|
|
164
|
+
- !ruby/object:Gem::Dependency
|
|
165
|
+
name: timecop
|
|
166
|
+
requirement: !ruby/object:Gem::Requirement
|
|
167
|
+
requirements:
|
|
168
|
+
- - "~>"
|
|
169
|
+
- !ruby/object:Gem::Version
|
|
170
|
+
version: '0.9'
|
|
171
|
+
type: :development
|
|
172
|
+
prerelease: false
|
|
173
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
174
|
+
requirements:
|
|
175
|
+
- - "~>"
|
|
176
|
+
- !ruby/object:Gem::Version
|
|
177
|
+
version: '0.9'
|
|
178
|
+
- !ruby/object:Gem::Dependency
|
|
179
|
+
name: webmock
|
|
180
|
+
requirement: !ruby/object:Gem::Requirement
|
|
181
|
+
requirements:
|
|
182
|
+
- - "~>"
|
|
183
|
+
- !ruby/object:Gem::Version
|
|
184
|
+
version: '3.18'
|
|
185
|
+
type: :development
|
|
186
|
+
prerelease: false
|
|
187
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
188
|
+
requirements:
|
|
189
|
+
- - "~>"
|
|
190
|
+
- !ruby/object:Gem::Version
|
|
191
|
+
version: '3.18'
|
|
125
192
|
description: A basic adaptor to send HTTP requests and parse the responses. Intended
|
|
126
193
|
to bootstrap the quick writing of Adaptors for specific APIs, without having to
|
|
127
194
|
write the same old JSON request and processing time and time again.
|
|
@@ -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.
|
|
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:
|
|
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: []
|