api_adaptor 0.0.2 → 1.0.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 +7 -8
- data/.yardopts +10 -0
- data/CHANGELOG.md +42 -1
- data/CLAUDE.md +423 -0
- data/Gemfile.lock +68 -39
- data/README.md +211 -24
- data/Rakefile +7 -1
- data/fixtures/v1/integration/foo.json +3 -0
- data/lib/api_adaptor/base.rb +139 -2
- data/lib/api_adaptor/exceptions.rb +73 -4
- data/lib/api_adaptor/headers.rb +32 -0
- data/lib/api_adaptor/json_client.rb +337 -43
- data/lib/api_adaptor/list_response.rb +63 -21
- data/lib/api_adaptor/null_logger.rb +53 -39
- data/lib/api_adaptor/response.rb +108 -12
- data/lib/api_adaptor/variables.rb +37 -0
- data/lib/api_adaptor/version.rb +1 -1
- data/lib/api_adaptor.rb +31 -1
- metadata +117 -7
data/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# ApiAdaptor
|
|
2
2
|
|
|
3
|
+
[](https://github.com/huwd/api_adaptor/actions/workflows/quality-checks.yml)
|
|
4
|
+
[](https://badge.fury.io/rb/api_adaptor)
|
|
5
|
+
[](https://huwd.github.io/api_adaptor/)
|
|
6
|
+
|
|
3
7
|
A basic adaptor to send HTTP requests and parse the responses.
|
|
4
8
|
Intended to bootstrap the quick writing of Adaptors for specific APIs, without having to write the same old JSON request and processing time and time again.
|
|
5
9
|
|
|
@@ -17,64 +21,177 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
|
17
21
|
gem install api_adaptor
|
|
18
22
|
```
|
|
19
23
|
|
|
24
|
+
## API Documentation
|
|
25
|
+
|
|
26
|
+
Full API documentation is available at [https://huwd.github.io/api_adaptor/](https://huwd.github.io/api_adaptor/)
|
|
27
|
+
|
|
28
|
+
Generate documentation locally:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle exec yard # Generate docs to doc/
|
|
32
|
+
bundle exec yard server # Preview at http://localhost:8808
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Releasing
|
|
36
|
+
|
|
37
|
+
Publishing is handled by GitHub Actions when you push a version tag.
|
|
38
|
+
|
|
39
|
+
- RubyGems publishing uses **Trusted Publishing (OIDC)** via `rubygems/release-gem`
|
|
40
|
+
- Ensure `api_adaptor` is configured on RubyGems.org with this repository/workflow as a trusted publisher.
|
|
41
|
+
- Bump `ApiAdaptor::VERSION` in `lib/api_adaptor/version.rb`.
|
|
42
|
+
- Tag the release as `vX.Y.Z` (must match `ApiAdaptor::VERSION`) and push the tag:
|
|
43
|
+
|
|
44
|
+
```shell
|
|
45
|
+
git tag v0.1.1
|
|
46
|
+
git push origin v0.1.1
|
|
47
|
+
```
|
|
48
|
+
|
|
20
49
|
## Usage
|
|
21
50
|
|
|
22
51
|
Use the ApiAdaptor as a base class for your API wrapper, for example:
|
|
23
52
|
|
|
24
53
|
```ruby
|
|
25
|
-
class MyApi < ApiAdaptor::Base
|
|
26
|
-
def base_url
|
|
27
|
-
endpoint
|
|
28
|
-
end
|
|
29
|
-
end
|
|
54
|
+
class MyApi < ApiAdaptor::Base; end
|
|
30
55
|
```
|
|
31
56
|
|
|
32
57
|
Use your new class to create a client that can make HTTP requests to JSON APIs for:
|
|
33
58
|
|
|
34
|
-
### GET JSON
|
|
35
|
-
|
|
36
59
|
```ruby
|
|
37
60
|
client = MyApi.new
|
|
38
|
-
|
|
61
|
+
client.get_json("http://some.endpoint/json")
|
|
62
|
+
client.post_json("http://some.endpoint/json", { "foo": "bar" })
|
|
63
|
+
client.put_json("http://some.endpoint/json", { "foo": "bar" })
|
|
64
|
+
client.patch_json("http://some.endpoint/json", { "foo": "bar" })
|
|
65
|
+
client.delete_json("http://some.endpoint/json", { "foo": "bar" })
|
|
39
66
|
```
|
|
40
67
|
|
|
41
|
-
|
|
68
|
+
You can also get a raw response from the API
|
|
42
69
|
|
|
43
70
|
```ruby
|
|
44
|
-
client
|
|
45
|
-
|
|
71
|
+
client.get_raw("http://some.endpoint/json")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Redirects (3xx)
|
|
75
|
+
|
|
76
|
+
Some APIs return a `3xx` response (commonly `307`/`308`) with a `Location` header that points to the “real” URL.
|
|
77
|
+
`ApiAdaptor::JsonClient` follows these redirects in a controlled way so callers consistently receive a final JSON response.
|
|
78
|
+
|
|
79
|
+
#### Defaults
|
|
80
|
+
|
|
81
|
+
- Redirects are followed for `GET`/`HEAD` when the status is `301`, `302`, `303`, `307`, or `308`.
|
|
82
|
+
- `max_redirects` defaults to `3`.
|
|
83
|
+
- Cross-origin redirects (scheme/host/port change) are allowed by default, but credentials are **not** forwarded.
|
|
84
|
+
- Non-GET requests (`POST`, `PUT`, `PATCH`, `DELETE`) do **not** follow redirects by default.
|
|
85
|
+
|
|
86
|
+
#### Configuration
|
|
87
|
+
|
|
88
|
+
You can configure redirect behaviour by passing options into your `ApiAdaptor::Base` subclass (they are forwarded to `ApiAdaptor::JsonClient`):
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
client = MyApi.new(
|
|
92
|
+
"https://example.com",
|
|
93
|
+
max_redirects: 3,
|
|
94
|
+
allow_cross_origin_redirects: true,
|
|
95
|
+
forward_auth_on_cross_origin_redirects: false,
|
|
96
|
+
follow_non_get_redirects: false
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
client.get_json("https://example.com/some/endpoint.json")
|
|
46
100
|
```
|
|
47
101
|
|
|
48
|
-
|
|
102
|
+
Or, if you are using `ApiAdaptor::JsonClient` directly:
|
|
49
103
|
|
|
50
104
|
```ruby
|
|
51
|
-
client =
|
|
52
|
-
|
|
105
|
+
client = ApiAdaptor::JsonClient.new(
|
|
106
|
+
bearer_token: "SOME_BEARER_TOKEN",
|
|
107
|
+
max_redirects: 3,
|
|
108
|
+
allow_cross_origin_redirects: true,
|
|
109
|
+
forward_auth_on_cross_origin_redirects: false
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
client.get_json("https://example.com/some/endpoint.json")
|
|
53
113
|
```
|
|
54
114
|
|
|
55
|
-
|
|
115
|
+
#### Security: auth and cross-origin redirects
|
|
116
|
+
|
|
117
|
+
Following redirects across origins can accidentally leak credentials (for example `Authorization` bearer tokens) to an unexpected host.
|
|
118
|
+
To reduce risk:
|
|
119
|
+
|
|
120
|
+
- By default, when the redirect target is cross-origin, `Authorization` is stripped and basic auth is not applied.
|
|
121
|
+
- To completely prevent cross-origin redirects, set `allow_cross_origin_redirects: false`.
|
|
122
|
+
- Only set `forward_auth_on_cross_origin_redirects: true` if you fully trust the redirect target.
|
|
123
|
+
|
|
124
|
+
#### Non-GET redirects (risk of replay)
|
|
125
|
+
|
|
126
|
+
Redirects for non-GET requests are risky because they may cause a request to be replayed (and potentially create duplicate side effects).
|
|
127
|
+
For that reason, redirect-following is disabled for non-GET requests by default.
|
|
128
|
+
|
|
129
|
+
If you do want to follow `307`/`308` for non-GET requests, you can opt in:
|
|
56
130
|
|
|
57
131
|
```ruby
|
|
58
|
-
client = MyApi.new
|
|
59
|
-
|
|
132
|
+
client = MyApi.new(
|
|
133
|
+
"https://example.com",
|
|
134
|
+
follow_non_get_redirects: true
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
client.post_json("https://example.com/some/endpoint.json", { "a" => 1 })
|
|
60
138
|
```
|
|
61
139
|
|
|
62
|
-
|
|
140
|
+
#### Handling redirect failures
|
|
141
|
+
|
|
142
|
+
You can rescue these redirect-specific exceptions:
|
|
143
|
+
|
|
144
|
+
- `ApiAdaptor::TooManyRedirects` (exceeded `max_redirects`)
|
|
145
|
+
- `ApiAdaptor::RedirectLocationMissing` (a redirect response without a usable `Location`)
|
|
146
|
+
|
|
147
|
+
Example:
|
|
63
148
|
|
|
64
149
|
```ruby
|
|
65
|
-
|
|
66
|
-
|
|
150
|
+
begin
|
|
151
|
+
client.get_json("https://example.com/some/endpoint.json")
|
|
152
|
+
rescue ApiAdaptor::TooManyRedirects, ApiAdaptor::RedirectLocationMissing => e
|
|
153
|
+
# handle / log / retry / surface a friendly message
|
|
154
|
+
raise e
|
|
155
|
+
end
|
|
67
156
|
```
|
|
68
157
|
|
|
69
|
-
###
|
|
158
|
+
### Conventional usage
|
|
159
|
+
|
|
160
|
+
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.
|
|
70
161
|
|
|
71
|
-
|
|
162
|
+
A REST API module can be created with:
|
|
72
163
|
|
|
73
164
|
```ruby
|
|
74
|
-
|
|
75
|
-
|
|
165
|
+
module MyApiAdaptor
|
|
166
|
+
# Wikidata REST API class
|
|
167
|
+
class RestApi < ApiAdaptor::Base
|
|
168
|
+
def get_foo(foo_id)
|
|
169
|
+
get_json("#{endpoint}/foo/#{CGI.escape(foo_id)}")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
76
173
|
```
|
|
77
174
|
|
|
175
|
+
and can be wrapped in a top level module:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
module MyApiAdaptor
|
|
179
|
+
class Error < StandardError; end
|
|
180
|
+
|
|
181
|
+
def self.rest_endpoint
|
|
182
|
+
ENV["MYAPI_REST_ENDPOINT"] || "https://example.com"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.rest_api
|
|
186
|
+
MyApiAdaptor::RestApi.new(rest_endpoint)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The intended convention is to have test helpers ship alongside the actual Adaptor code.
|
|
192
|
+
See [WikiData examples here](https://github.com/huwd/wikidata_adaptor/blob/main/lib/wikidata_adaptor/test_helpers/rest_api.rb).
|
|
193
|
+
This allows other applications that integrate the API Adaptor to easily mock out calls and receive representative data back.
|
|
194
|
+
|
|
78
195
|
## Environment variables
|
|
79
196
|
|
|
80
197
|
User Agent is populated with a default string.
|
|
@@ -94,6 +211,76 @@ User agent would read
|
|
|
94
211
|
test_app/1.0.0 (contact@example.com)
|
|
95
212
|
```
|
|
96
213
|
|
|
214
|
+
## Development
|
|
215
|
+
|
|
216
|
+
After checking out the repo, run `bundle install` to install dependencies.
|
|
217
|
+
|
|
218
|
+
### Running Tests
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
bundle exec rspec # Run tests only
|
|
222
|
+
bundle exec rubocop # Run linter only
|
|
223
|
+
bundle exec rake # Run tests, linter, and build docs
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Code Coverage
|
|
227
|
+
|
|
228
|
+
SimpleCov tracks test coverage. View the report at `coverage/index.html` after running tests.
|
|
229
|
+
|
|
230
|
+
Current coverage: 92.3% line, 81.65% branch
|
|
231
|
+
|
|
232
|
+
### Documentation
|
|
233
|
+
|
|
234
|
+
Generate YARD documentation:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
bundle exec yard # Generate docs to doc/
|
|
238
|
+
bundle exec yard server # Preview at http://localhost:8808
|
|
239
|
+
bundle exec yard stats # View documentation coverage
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Quality Standards
|
|
243
|
+
|
|
244
|
+
- **Tests:** RSpec with WebMock for HTTP mocking
|
|
245
|
+
- **Linting:** RuboCop with rubocop-yard for documentation
|
|
246
|
+
- **Coverage:** SimpleCov (minimum 80% line, 75% branch)
|
|
247
|
+
- **Documentation:** YARD for all public APIs
|
|
248
|
+
|
|
249
|
+
## Troubleshooting
|
|
250
|
+
|
|
251
|
+
### Redirects Not Following
|
|
252
|
+
|
|
253
|
+
By default, only GET/HEAD requests follow redirects. For POST/PUT/PATCH/DELETE:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
client = MyApi.new("https://example.com", follow_non_get_redirects: true)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Timeout Errors
|
|
260
|
+
|
|
261
|
+
Default timeout is 4 seconds. Configure longer timeouts:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
client = MyApi.new("https://example.com", timeout: 10)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Cross-Origin Redirect Security
|
|
268
|
+
|
|
269
|
+
By default, auth headers are stripped on cross-origin redirects. To allow (use with caution):
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
client = MyApi.new(
|
|
273
|
+
"https://example.com",
|
|
274
|
+
forward_auth_on_cross_origin_redirects: true # Security risk!
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Or disable cross-origin redirects entirely:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
client = MyApi.new("https://example.com", allow_cross_origin_redirects: false)
|
|
282
|
+
```
|
|
283
|
+
|
|
97
284
|
## Contributing
|
|
98
285
|
|
|
99
286
|
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).
|
data/Rakefile
CHANGED
data/lib/api_adaptor/base.rb
CHANGED
|
@@ -6,20 +6,113 @@ require_relative "null_logger"
|
|
|
6
6
|
require_relative "list_response"
|
|
7
7
|
|
|
8
8
|
module ApiAdaptor
|
|
9
|
+
# Base class for building API-specific clients.
|
|
10
|
+
#
|
|
11
|
+
# Provides common functionality for JSON API clients including HTTP method delegation,
|
|
12
|
+
# URL construction, and pagination support. Subclass this to create clients for specific APIs.
|
|
13
|
+
#
|
|
14
|
+
# @example Creating a custom API client
|
|
15
|
+
# class MyApiClient < ApiAdaptor::Base
|
|
16
|
+
# def initialize
|
|
17
|
+
# super("https://api.example.com", bearer_token: "abc123")
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# def get_user(id)
|
|
21
|
+
# get_json("/users/#{id}")
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# def list_posts(page: 1)
|
|
25
|
+
# get_list("/posts?page=#{page}")
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @example Using default options
|
|
30
|
+
# ApiAdaptor::Base.default_options = { timeout: 10 }
|
|
31
|
+
# client = MyApiClient.new # Inherits 10-second timeout
|
|
32
|
+
#
|
|
33
|
+
# @see JSONClient for underlying HTTP client options
|
|
9
34
|
class Base
|
|
35
|
+
# Raised when an invalid API URL is provided
|
|
10
36
|
class InvalidAPIURL < StandardError
|
|
11
37
|
end
|
|
12
38
|
|
|
13
39
|
extend Forwardable
|
|
14
40
|
|
|
41
|
+
# Returns the underlying JSONClient instance, creating it if necessary
|
|
42
|
+
#
|
|
43
|
+
# @return [JSONClient] The HTTP client instance
|
|
15
44
|
def client
|
|
16
45
|
@client ||= create_client
|
|
17
46
|
end
|
|
18
47
|
|
|
48
|
+
# Creates a new JSONClient with the configured options
|
|
49
|
+
#
|
|
50
|
+
# @return [JSONClient] A new HTTP client instance
|
|
19
51
|
def create_client
|
|
20
52
|
ApiAdaptor::JsonClient.new(options)
|
|
21
53
|
end
|
|
22
54
|
|
|
55
|
+
# @!method get_json(url, &block)
|
|
56
|
+
# Performs a GET request and parses JSON response
|
|
57
|
+
# @param url [String] The URL to request
|
|
58
|
+
# @yield [Hash] The parsed JSON response
|
|
59
|
+
# @return [Response, Object] Response object or yielded value
|
|
60
|
+
# @see JSONClient#get_json
|
|
61
|
+
#
|
|
62
|
+
# @!method post_json(url, params = {})
|
|
63
|
+
# Performs a POST request with JSON body
|
|
64
|
+
# @param url [String] The URL to request
|
|
65
|
+
# @param params [Hash] Data to send as JSON
|
|
66
|
+
# @return [Response] Response object
|
|
67
|
+
# @see JSONClient#post_json
|
|
68
|
+
#
|
|
69
|
+
# @!method put_json(url, params = {})
|
|
70
|
+
# Performs a PUT request with JSON body
|
|
71
|
+
# @param url [String] The URL to request
|
|
72
|
+
# @param params [Hash] Data to send as JSON
|
|
73
|
+
# @return [Response] Response object
|
|
74
|
+
# @see JSONClient#put_json
|
|
75
|
+
#
|
|
76
|
+
# @!method patch_json(url, params = {})
|
|
77
|
+
# Performs a PATCH request with JSON body
|
|
78
|
+
# @param url [String] The URL to request
|
|
79
|
+
# @param params [Hash] Data to send as JSON
|
|
80
|
+
# @return [Response] Response object
|
|
81
|
+
# @see JSONClient#patch_json
|
|
82
|
+
#
|
|
83
|
+
# @!method delete_json(url, params = {})
|
|
84
|
+
# Performs a DELETE request
|
|
85
|
+
# @param url [String] The URL to request
|
|
86
|
+
# @param params [Hash] Optional data to send as JSON
|
|
87
|
+
# @return [Response] Response object
|
|
88
|
+
# @see JSONClient#delete_json
|
|
89
|
+
#
|
|
90
|
+
# @!method get_raw(url)
|
|
91
|
+
# Performs a GET request and returns raw response
|
|
92
|
+
# @param url [String] The URL to request
|
|
93
|
+
# @return [RestClient::Response] Raw response object
|
|
94
|
+
# @see JSONClient#get_raw
|
|
95
|
+
#
|
|
96
|
+
# @!method get_raw!(url)
|
|
97
|
+
# Performs a GET request and returns raw response, raising on errors
|
|
98
|
+
# @param url [String] The URL to request
|
|
99
|
+
# @return [RestClient::Response] Raw response object
|
|
100
|
+
# @raise [HTTPClientError, HTTPServerError] On HTTP errors
|
|
101
|
+
# @see JSONClient#get_raw!
|
|
102
|
+
#
|
|
103
|
+
# @!method put_multipart(url, params = {})
|
|
104
|
+
# Performs a PUT request with multipart/form-data
|
|
105
|
+
# @param url [String] The URL to request
|
|
106
|
+
# @param params [Hash] Multipart form data
|
|
107
|
+
# @return [Response] Response object
|
|
108
|
+
# @see JSONClient#put_multipart
|
|
109
|
+
#
|
|
110
|
+
# @!method post_multipart(url, params = {})
|
|
111
|
+
# Performs a POST request with multipart/form-data
|
|
112
|
+
# @param url [String] The URL to request
|
|
113
|
+
# @param params [Hash] Multipart form data
|
|
114
|
+
# @return [Response] Response object
|
|
115
|
+
# @see JSONClient#post_multipart
|
|
23
116
|
def_delegators :client,
|
|
24
117
|
:get_json,
|
|
25
118
|
:post_json,
|
|
@@ -31,20 +124,43 @@ module ApiAdaptor
|
|
|
31
124
|
:put_multipart,
|
|
32
125
|
:post_multipart
|
|
33
126
|
|
|
127
|
+
# @return [Hash] The client configuration options
|
|
34
128
|
attr_reader :options
|
|
35
129
|
|
|
36
130
|
class << self
|
|
131
|
+
# @!attribute [w] logger
|
|
132
|
+
# Sets the default logger for all Base instances
|
|
133
|
+
# @param value [Logger] Logger instance
|
|
37
134
|
attr_writer :logger
|
|
135
|
+
|
|
136
|
+
# @!attribute [rw] default_options
|
|
137
|
+
# Default options merged into all Base instances
|
|
138
|
+
# @return [Hash, nil] Default options hash
|
|
38
139
|
attr_accessor :default_options
|
|
39
140
|
end
|
|
40
141
|
|
|
142
|
+
# Returns the default logger for Base instances
|
|
143
|
+
#
|
|
144
|
+
# @return [Logger] Logger instance (defaults to NullLogger)
|
|
41
145
|
def self.logger
|
|
42
146
|
@logger ||= ApiAdaptor::NullLogger.new
|
|
43
147
|
end
|
|
44
148
|
|
|
45
|
-
|
|
149
|
+
# Initializes a new API client
|
|
150
|
+
#
|
|
151
|
+
# @param endpoint_url [String, nil] Base URL for the API
|
|
152
|
+
# @param options [Hash] Configuration options (see JSONClient#initialize for details)
|
|
153
|
+
#
|
|
154
|
+
# @raise [InvalidAPIURL] If endpoint_url is invalid
|
|
155
|
+
#
|
|
156
|
+
# @example Basic initialization
|
|
157
|
+
# client = Base.new("https://api.example.com")
|
|
158
|
+
#
|
|
159
|
+
# @example With authentication
|
|
160
|
+
# client = Base.new("https://api.example.com", bearer_token: "abc123")
|
|
161
|
+
def initialize(endpoint_url = nil, options = {})
|
|
46
162
|
options[:endpoint_url] = endpoint_url
|
|
47
|
-
raise InvalidAPIURL
|
|
163
|
+
raise InvalidAPIURL if !endpoint_url.nil? && endpoint_url !~ URI::RFC3986_Parser::RFC3986_URI
|
|
48
164
|
|
|
49
165
|
base_options = { logger: ApiAdaptor::Base.logger }
|
|
50
166
|
default_options = base_options.merge(ApiAdaptor::Base.default_options || {})
|
|
@@ -52,10 +168,31 @@ module ApiAdaptor
|
|
|
52
168
|
self.endpoint = options[:endpoint_url]
|
|
53
169
|
end
|
|
54
170
|
|
|
171
|
+
# Constructs a URL for a given slug with query parameters
|
|
172
|
+
#
|
|
173
|
+
# @param slug [String] The API endpoint slug
|
|
174
|
+
# @param options [Hash] Query parameters to append
|
|
175
|
+
#
|
|
176
|
+
# @return [String] Full URL with .json extension and query string
|
|
177
|
+
#
|
|
178
|
+
# @example
|
|
179
|
+
# url_for_slug("users/123", include: "posts")
|
|
180
|
+
# # => "https://api.example.com/users/123.json?include=posts"
|
|
55
181
|
def url_for_slug(slug, options = {})
|
|
56
182
|
"#{base_url}/#{slug}.json#{query_string(options)}"
|
|
57
183
|
end
|
|
58
184
|
|
|
185
|
+
# Performs a GET request and wraps the response in a ListResponse
|
|
186
|
+
#
|
|
187
|
+
# @param url [String] The URL to request
|
|
188
|
+
#
|
|
189
|
+
# @return [ListResponse] Paginated response wrapper
|
|
190
|
+
#
|
|
191
|
+
# @example
|
|
192
|
+
# list = client.get_list("/posts?page=1")
|
|
193
|
+
# list.results # => Array of items
|
|
194
|
+
# list.current_page # => 1
|
|
195
|
+
# list.total_pages # => 10
|
|
59
196
|
def get_list(url)
|
|
60
197
|
get_json(url) do |r|
|
|
61
198
|
ApiAdaptor::ListResponse.new(r, self)
|
|
@@ -1,21 +1,55 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ApiAdaptor
|
|
4
|
-
#
|
|
4
|
+
# Base exception class for all ApiAdaptor errors
|
|
5
5
|
class BaseError < StandardError; end
|
|
6
6
|
|
|
7
|
+
# Raised when too many redirects are followed
|
|
8
|
+
#
|
|
9
|
+
# @see JSONClient#max_redirects
|
|
10
|
+
class TooManyRedirects < BaseError; end
|
|
11
|
+
|
|
12
|
+
# Raised when a redirect response is missing the Location header
|
|
13
|
+
class RedirectLocationMissing < BaseError; end
|
|
14
|
+
|
|
15
|
+
# Raised when connection to the endpoint is refused (ECONNREFUSED)
|
|
7
16
|
class EndpointNotFound < BaseError; end
|
|
8
17
|
|
|
18
|
+
# Raised when a request times out
|
|
19
|
+
#
|
|
20
|
+
# @see JSONClient#initialize for timeout configuration
|
|
9
21
|
class TimedOutException < BaseError; end
|
|
10
22
|
|
|
23
|
+
# Raised when an invalid URL is provided
|
|
11
24
|
class InvalidUrl < BaseError; end
|
|
12
25
|
|
|
26
|
+
# Raised when a socket error occurs during the request
|
|
13
27
|
class SocketErrorException < BaseError; end
|
|
14
28
|
|
|
15
|
-
#
|
|
29
|
+
# Base class for all HTTP 4xx and 5xx error responses
|
|
30
|
+
#
|
|
31
|
+
# Provides access to the HTTP status code, error details, and response body.
|
|
32
|
+
#
|
|
33
|
+
# @example Handling HTTP errors
|
|
34
|
+
# begin
|
|
35
|
+
# client.get_json(url)
|
|
36
|
+
# rescue ApiAdaptor::HTTPNotFound => e
|
|
37
|
+
# puts "Resource not found: #{e.code}"
|
|
38
|
+
# rescue ApiAdaptor::HTTPServerError => e
|
|
39
|
+
# puts "Server error: #{e.code} - #{e.error_details}"
|
|
40
|
+
# end
|
|
16
41
|
class HTTPErrorResponse < BaseError
|
|
42
|
+
# @return [Integer] HTTP status code
|
|
43
|
+
# @return [Hash, nil] Parsed error details from response body
|
|
44
|
+
# @return [String, nil] Raw HTTP response body
|
|
17
45
|
attr_accessor :code, :error_details, :http_body
|
|
18
46
|
|
|
47
|
+
# Initializes a new HTTP error response
|
|
48
|
+
#
|
|
49
|
+
# @param code [Integer] HTTP status code
|
|
50
|
+
# @param message [String, nil] Error message
|
|
51
|
+
# @param error_details [Hash, nil] Parsed error details from JSON body
|
|
52
|
+
# @param http_body [String, nil] Raw HTTP response body
|
|
19
53
|
def initialize(code, message = nil, error_details = nil, http_body = nil)
|
|
20
54
|
super(message)
|
|
21
55
|
@code = code
|
|
@@ -24,49 +58,84 @@ module ApiAdaptor
|
|
|
24
58
|
end
|
|
25
59
|
end
|
|
26
60
|
|
|
27
|
-
#
|
|
61
|
+
# Base class for all HTTP 4xx client errors
|
|
28
62
|
class HTTPClientError < HTTPErrorResponse; end
|
|
29
63
|
|
|
64
|
+
# Base class for intermittent client errors that may succeed on retry
|
|
30
65
|
class HTTPIntermittentClientError < HTTPClientError; end
|
|
31
66
|
|
|
67
|
+
# Raised on HTTP 404 Not Found
|
|
32
68
|
class HTTPNotFound < HTTPClientError; end
|
|
33
69
|
|
|
70
|
+
# Raised on HTTP 410 Gone
|
|
34
71
|
class HTTPGone < HTTPClientError; end
|
|
35
72
|
|
|
73
|
+
# Raised on HTTP 413 Payload Too Large
|
|
36
74
|
class HTTPPayloadTooLarge < HTTPClientError; end
|
|
37
75
|
|
|
76
|
+
# Raised on HTTP 401 Unauthorized
|
|
38
77
|
class HTTPUnauthorized < HTTPClientError; end
|
|
39
78
|
|
|
79
|
+
# Raised on HTTP 403 Forbidden
|
|
40
80
|
class HTTPForbidden < HTTPClientError; end
|
|
41
81
|
|
|
82
|
+
# Raised on HTTP 409 Conflict
|
|
42
83
|
class HTTPConflict < HTTPClientError; end
|
|
43
84
|
|
|
85
|
+
# Raised on HTTP 422 Unprocessable Entity
|
|
44
86
|
class HTTPUnprocessableEntity < HTTPClientError; end
|
|
45
87
|
|
|
88
|
+
# Raised on HTTP 422 Unprocessable Content (alternative name)
|
|
89
|
+
class HTTPUnprocessableContent < HTTPClientError; end
|
|
90
|
+
|
|
91
|
+
# Raised on HTTP 400 Bad Request
|
|
46
92
|
class HTTPBadRequest < HTTPClientError; end
|
|
47
93
|
|
|
94
|
+
# Raised on HTTP 429 Too Many Requests
|
|
48
95
|
class HTTPTooManyRequests < HTTPIntermittentClientError; end
|
|
49
96
|
|
|
50
|
-
#
|
|
97
|
+
# Base class for all HTTP 5xx server errors
|
|
51
98
|
class HTTPServerError < HTTPErrorResponse; end
|
|
52
99
|
|
|
100
|
+
# Base class for intermittent server errors that may succeed on retry
|
|
53
101
|
class HTTPIntermittentServerError < HTTPServerError; end
|
|
54
102
|
|
|
103
|
+
# Raised on HTTP 500 Internal Server Error
|
|
55
104
|
class HTTPInternalServerError < HTTPServerError; end
|
|
56
105
|
|
|
106
|
+
# Raised on HTTP 502 Bad Gateway
|
|
57
107
|
class HTTPBadGateway < HTTPIntermittentServerError; end
|
|
58
108
|
|
|
109
|
+
# Raised on HTTP 503 Service Unavailable
|
|
59
110
|
class HTTPUnavailable < HTTPIntermittentServerError; end
|
|
60
111
|
|
|
112
|
+
# Raised on HTTP 504 Gateway Timeout
|
|
61
113
|
class HTTPGatewayTimeout < HTTPIntermittentServerError; end
|
|
62
114
|
|
|
115
|
+
# Module providing HTTP error handling and exception mapping
|
|
63
116
|
module ExceptionHandling
|
|
117
|
+
# Builds a specific HTTP error exception based on the status code
|
|
118
|
+
#
|
|
119
|
+
# @param error [RestClient::Exception] The RestClient exception
|
|
120
|
+
# @param url [String] The URL that was requested
|
|
121
|
+
# @param details [Hash, nil] Parsed error details from JSON response
|
|
122
|
+
#
|
|
123
|
+
# @return [HTTPErrorResponse] Specific exception instance
|
|
124
|
+
#
|
|
125
|
+
# @api private
|
|
64
126
|
def build_specific_http_error(error, url, details = nil)
|
|
65
127
|
message = "URL: #{url}\nResponse body:\n#{error.http_body}"
|
|
66
128
|
code = error.http_code
|
|
67
129
|
error_class_for_code(code).new(code, message, details, error.http_body)
|
|
68
130
|
end
|
|
69
131
|
|
|
132
|
+
# Maps HTTP status codes to exception classes
|
|
133
|
+
#
|
|
134
|
+
# @param code [Integer] HTTP status code
|
|
135
|
+
#
|
|
136
|
+
# @return [Class] Exception class for the status code
|
|
137
|
+
#
|
|
138
|
+
# @api private
|
|
70
139
|
def error_class_for_code(code)
|
|
71
140
|
case code
|
|
72
141
|
when 400
|
data/lib/api_adaptor/headers.rb
CHANGED
|
@@ -1,22 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ApiAdaptor
|
|
4
|
+
# Thread-safe header management for HTTP requests
|
|
5
|
+
#
|
|
6
|
+
# Headers are stored in thread-local storage, allowing different threads
|
|
7
|
+
# to maintain separate header contexts without interference.
|
|
8
|
+
#
|
|
9
|
+
# @example Setting custom headers
|
|
10
|
+
# ApiAdaptor::Headers.set_header("X-Request-ID", "12345")
|
|
11
|
+
# ApiAdaptor::Headers.set_header("X-Correlation-ID", "abcde")
|
|
12
|
+
#
|
|
13
|
+
# @example Getting all headers
|
|
14
|
+
# headers = ApiAdaptor::Headers.headers
|
|
15
|
+
# # => {"X-Request-ID" => "12345", "X-Correlation-ID" => "abcde"}
|
|
16
|
+
#
|
|
17
|
+
# @example Clearing headers
|
|
18
|
+
# ApiAdaptor::Headers.clear_headers
|
|
4
19
|
class Headers
|
|
5
20
|
class << self
|
|
21
|
+
# Sets a header value for the current thread
|
|
22
|
+
#
|
|
23
|
+
# @param header_name [String] Header name
|
|
24
|
+
# @param value [String] Header value
|
|
25
|
+
#
|
|
26
|
+
# @return [String] The value that was set
|
|
6
27
|
def set_header(header_name, value)
|
|
7
28
|
header_data[header_name] = value
|
|
8
29
|
end
|
|
9
30
|
|
|
31
|
+
# Returns all non-empty headers for the current thread
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash] Hash of header names to values, excluding nil/empty values
|
|
10
34
|
def headers
|
|
11
35
|
header_data.reject { |_k, v| v.nil? || v.empty? }
|
|
12
36
|
end
|
|
13
37
|
|
|
38
|
+
# Clears all headers for the current thread
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash] Empty hash
|
|
14
41
|
def clear_headers
|
|
15
42
|
Thread.current[:headers] = {}
|
|
16
43
|
end
|
|
17
44
|
|
|
18
45
|
private
|
|
19
46
|
|
|
47
|
+
# Returns the thread-local header storage
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash] Thread-local header hash
|
|
50
|
+
#
|
|
51
|
+
# @api private
|
|
20
52
|
def header_data
|
|
21
53
|
Thread.current[:headers] ||= {}
|
|
22
54
|
end
|