client-api-builder 0.7.0 → 0.7.2
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/CHANGELOG.md +52 -3
- data/README.md +164 -34
- data/lib/client-api-builder.rb +1 -0
- data/lib/client_api_builder/active_support_log_subscriber.rb +18 -5
- data/lib/client_api_builder/active_support_notifications.rb +4 -14
- data/lib/client_api_builder/nested_router.rb +1 -1
- data/lib/client_api_builder/net_http_request.rb +55 -23
- data/lib/client_api_builder/route_value_validator.rb +54 -0
- data/lib/client_api_builder/router.rb +146 -101
- data/lib/client_api_builder/section.rb +21 -9
- data/lib/client_api_builder/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aac435317feb174db101e201b00464222bfc2df0f10601ac88e7897c3694be27
|
|
4
|
+
data.tar.gz: '08ad14efc0bff6c1f6e7fd4864e26c6f41853d72808fdc84b0e4781a0cfd229f'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e28726cc8c9670553371ba62a3c2ea4d30a4edb5db413936b91810ab1b592b2b84e6dd0fd3bcac8ad47fa2fb2c1b5d57a6c0443cace95210b9cf203a5e783d43
|
|
7
|
+
data.tar.gz: ed7628816de37ff4e8f4a27f67520f277abac439cb6183eca0c98ce5a072a1bd8c0413a3db5bda145d75208b4272c598b89514ad80f6692a6cb300c255330b64
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.2](https://github.com/dougyouch/client-api-builder/compare/v0.7.1...v0.7.2) (2026-10-03)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **notifications:** report request exceptions to subscribers ([5c29e71](https://github.com/dougyouch/client-api-builder/commit/5c29e71b2ed23016fa68fce3b3aeda3cebdd1e21))
|
|
9
|
+
* **router:** check the base URL used for each request ([e9ea9c8](https://github.com/dougyouch/client-api-builder/commit/e9ea9c808569adf251247a849d6d5727cd3d2772))
|
|
10
|
+
* **router:** clear the previous response before each attempt ([861ae3e](https://github.com/dougyouch/client-api-builder/commit/861ae3e9fd73b45cbb7be33de09b3cf19ffae028))
|
|
11
|
+
* **router:** clear the response block when a route is redefined without one ([cca46c3](https://github.com/dougyouch/client-api-builder/commit/cca46c3e87176d37e953de1ec159b4cb3fd5f4a0))
|
|
12
|
+
* **router:** fill in every placeholder in query and body strings ([beb2d89](https://github.com/dougyouch/client-api-builder/commit/beb2d890f20423b792733919e4fded51fe47c5ee))
|
|
13
|
+
* **router:** only resolve symbols and procs for class-level headers and query params ([bfa29f1](https://github.com/dougyouch/client-api-builder/commit/bfa29f18d28e32eff4268b3dd3343162289140d1))
|
|
14
|
+
* **router:** reject route values that can't be compiled into the generated method ([2a5cdad](https://github.com/dougyouch/client-api-builder/commit/2a5cdad5c338ee8f05b0a3b3739c937f1fa48375))
|
|
15
|
+
* **router:** require a whole verb when detecting the HTTP method ([0bfa860](https://github.com/dougyouch/client-api-builder/commit/0bfa8601a454e8ecf27a496dd894a7a8c67a2351))
|
|
16
|
+
* **router:** stop route from modifying the caller's query and body ([faaee75](https://github.com/dougyouch/client-api-builder/commit/faaee756c81d258516422405ee3ac63fe9227771))
|
|
17
|
+
* **router:** treat a colon after a word as literal path text ([5dae4b0](https://github.com/dougyouch/client-api-builder/commit/5dae4b0bb3e0b850775f62541024d3e441cf87a1))
|
|
18
|
+
* **section:** define section methods with closures instead of generated source ([d9e23a6](https://github.com/dougyouch/client-api-builder/commit/d9e23a6c8bb010ed5a76993fd0ec6014cd0acb18))
|
|
19
|
+
* **stream:** check the response status before streaming the body ([eeb9e3b](https://github.com/dougyouch/client-api-builder/commit/eeb9e3b3895f4571bff147e5f75695e4f7324868))
|
|
20
|
+
* **stream:** reject only parent path segments in file names ([09c355d](https://github.com/dougyouch/client-api-builder/commit/09c355dc3c68dd06e07c8c9dfe003d7daf1153f6))
|
|
21
|
+
|
|
22
|
+
### Upgrade Notes
|
|
23
|
+
|
|
24
|
+
* Per-request `headers:` and `query:` values and route arguments are now sent as given. A Symbol there (e.g. `query: { order: :desc }`) is sent as the value `desc` instead of calling a method of that name. Symbols and blocks given to the class-level `header` and `query_param` are still resolved on the client.
|
|
25
|
+
* HTTP method detection now needs the whole verb: route names where the verb runs into the next word (`posts`, `addresses`, `deleted_users`, `updates_feed`, `changelog`) now default to GET instead of POST, PUT or DELETE. Add `method:` to such routes to keep the old method.
|
|
26
|
+
* Redefining a route without a block, in the same class or a subclass, no longer keeps the previous or inherited response block. Pass the block again to keep it.
|
|
27
|
+
* A `:name` directly after a letter, digit, `_` or `}` is now literal path text, so `/v1/items:batchGet` works; a mid-word parameter such as `/items:id` no longer becomes an argument.
|
|
28
|
+
* Route `query:`/`body:` values must be strings, numbers, booleans, `nil`, hashes or arrays. `Range`, `Regexp`, `BigDecimal`, `Complex`, `Time` and other objects now raise an `ArgumentError` naming the route when the class loads; write them as a string or use a `'{method}'` placeholder.
|
|
29
|
+
|
|
30
|
+
## [0.7.1](https://github.com/dougyouch/client-api-builder/compare/v0.7.0...v0.7.1) (2026-10-03)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Bug Fixes
|
|
34
|
+
|
|
35
|
+
* **router:** detect destroy_ routes as DELETE ([85604c4](https://github.com/dougyouch/client-api-builder/commit/85604c4d974c8483e292e700eb51a2c78e312b54))
|
|
36
|
+
* **router:** url-encode path values ([76d6fb4](https://github.com/dougyouch/client-api-builder/commit/76d6fb4fa246b342e263322d06262cba6760c278))
|
|
37
|
+
|
|
38
|
+
### Upgrade Notes
|
|
39
|
+
|
|
40
|
+
* Path values are now percent-encoded by `escape_path`, including `/`. A value like `'a/b'` that previously expanded into two path segments is now sent as one segment (`a%2Fb`). To keep `/` as a separator, override `escape_path` in your client, e.g. `value.to_s.split('/').map { |part| ERB::Util.url_encode(part) }.join('/')`.
|
|
41
|
+
|
|
3
42
|
## [0.7.0](https://github.com/dougyouch/client-api-builder/compare/v0.6.1...v0.7.0) (2026-10-03)
|
|
4
43
|
|
|
5
44
|
|
|
6
|
-
###
|
|
45
|
+
### ⚠ BREAKING CHANGES
|
|
46
|
+
|
|
47
|
+
* client-api-builder now requires Ruby 3.2 or newer (previously 3.0). Ruby 3.0 and 3.1 are end-of-life. ([7c951e0](https://github.com/dougyouch/client-api-builder/commit/7c951e0))
|
|
48
|
+
|
|
49
|
+
### Packaging
|
|
50
|
+
|
|
51
|
+
* ship only `lib/`, `README.md`, `LICENSE` and `CHANGELOG.md` in the gem instead of every tracked file ([ca141a1](https://github.com/dougyouch/client-api-builder/commit/ca141a1))
|
|
52
|
+
* refresh the gem summary and description ([ca141a1](https://github.com/dougyouch/client-api-builder/commit/ca141a1))
|
|
53
|
+
* add `ClientApiBuilder::VERSION` ([7c951e0](https://github.com/dougyouch/client-api-builder/commit/7c951e0))
|
|
7
54
|
|
|
8
|
-
|
|
55
|
+
### Build
|
|
9
56
|
|
|
10
|
-
|
|
57
|
+
* automate versioning, changelog and RubyGems publishing with release-please ([7c951e0](https://github.com/dougyouch/client-api-builder/commit/7c951e0))
|
|
58
|
+
* replace Codecov with a GitHub-hosted coverage badge ([7c951e0](https://github.com/dougyouch/client-api-builder/commit/7c951e0))
|
|
59
|
+
* adopt the dynamic-active-model RuboCop config with rubocop-rspec ([f36ed6f](https://github.com/dougyouch/client-api-builder/commit/f36ed6f))
|
data/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Client API Builder
|
|
2
2
|
|
|
3
|
-
[](https://rubygems.org/gems/client-api-builder)
|
|
4
4
|
[](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
|
|
5
5
|
[](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
|
|
6
7
|
|
|
7
8
|
A Ruby gem for building robust, secure API clients through declarative configuration. Define your API endpoints and their behavior with minimal boilerplate while benefiting from built-in security features, automatic retries, and comprehensive error handling.
|
|
8
9
|
|
|
@@ -93,25 +94,54 @@ route :method_name, '/path/:param', options
|
|
|
93
94
|
|
|
94
95
|
| Option | Description |
|
|
95
96
|
|--------|-------------|
|
|
96
|
-
| `method:` | HTTP method
|
|
97
|
+
| `method:` | HTTP method. Auto-detected from the route name if omitted. Any of `:get`, `:post`, `:put`, `:patch`, `:delete`, `:head`, `:options`, `:trace`, `:copy`, `:lock`, `:unlock`, `:mkcol`, `:move`, `:propfind`, `:proppatch`. |
|
|
97
98
|
| `query:` | Hash defining query parameters. Use symbols for dynamic values. |
|
|
98
|
-
| `body:` |
|
|
99
|
-
| `
|
|
99
|
+
| `body:` | Request body: a Hash or Array (symbols become arguments) or a literal String. |
|
|
100
|
+
| `no_body:` | `true` to send no body, even for POST/PUT/PATCH. |
|
|
101
|
+
| `has_body:` | `true` to add a `body:` argument to any method, e.g. a GET with a body. |
|
|
102
|
+
| `expected_response_code:` | Single expected HTTP status code. Without one, any 2xx response is accepted. |
|
|
100
103
|
| `expected_response_codes:` | Array of expected HTTP status codes |
|
|
101
104
|
| `stream:` | Enable streaming (`:file`, `:io`, `:block`, or `true`) |
|
|
102
105
|
| `return:` | Return type (`:response`, `:body`, or parsed JSON by default) |
|
|
103
106
|
|
|
107
|
+
POST, PUT and PATCH routes without a `body:` option take the request body as a `body:` argument:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
route :create_user, '/users'
|
|
111
|
+
|
|
112
|
+
client.create_user(body: { name: 'Ann' })
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Per-Request Options
|
|
116
|
+
|
|
117
|
+
Every generated method also accepts options that apply to that call only:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
client.get_user(
|
|
121
|
+
id: 1,
|
|
122
|
+
headers: { 'X-Trace-Id' => 'abc' }, # merged over the class headers; nil removes one
|
|
123
|
+
query: { expand: 'teams' }, # merged over the route's query params
|
|
124
|
+
body: { name: 'Ann' }, # replaces the route's body
|
|
125
|
+
connection_options: { read_timeout: 5 },
|
|
126
|
+
retries: 3, # attempts for this call
|
|
127
|
+
sleep: 0.5, # seconds between attempts
|
|
128
|
+
return: :body # :body or :response instead of parsed JSON
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
104
132
|
### Automatic HTTP Method Detection
|
|
105
133
|
|
|
106
|
-
The Router
|
|
134
|
+
The Router detects the HTTP method from how the route name starts:
|
|
107
135
|
|
|
108
|
-
|
|
|
109
|
-
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
|
|
|
136
|
+
| Name starts with the word | HTTP Method |
|
|
137
|
+
|------------------|-------------|
|
|
138
|
+
| `post`, `create`, `add`, `insert` | POST |
|
|
139
|
+
| `put`, `update`, `modify`, `change` | PUT |
|
|
140
|
+
| `patch` | PATCH |
|
|
141
|
+
| `delete`, `remove`, `destroy` | DELETE |
|
|
142
|
+
| anything else | GET |
|
|
143
|
+
|
|
144
|
+
The verb must be the whole name or be followed by `_`: `create_user` and `delete` are detected, while names such as `posts`, `addresses`, `deleted_users` or `updates_feed` are GET. Pass `method:` when the name doesn't say it.
|
|
115
145
|
|
|
116
146
|
```ruby
|
|
117
147
|
class MyApiClient
|
|
@@ -132,11 +162,43 @@ end
|
|
|
132
162
|
|
|
133
163
|
Parameters can be defined in three ways:
|
|
134
164
|
|
|
135
|
-
**1. Path Parameters** (using `:param`
|
|
165
|
+
**1. Path Parameters** (using `:param` syntax):
|
|
136
166
|
|
|
137
167
|
```ruby
|
|
138
168
|
route :get_user, '/users/:id'
|
|
139
|
-
|
|
169
|
+
# client.get_user(id: 1)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A colon directly after a letter, digit, `_` or `}` is literal text, so paths like `/v1/items:batchGet`, `/v1/{name}:cancel` or `/slots/12:30` need no escaping. Parameter names start with a letter or `_`.
|
|
173
|
+
|
|
174
|
+
`{name}` is filled from the client's own `name` method rather than an argument, which suits values like account IDs that are set once. If the route also has an argument called `name`, the argument is used instead (in sections, path values always come from the root client's method):
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
attr_accessor :account_id
|
|
178
|
+
|
|
179
|
+
route :get_invoices, '/accounts/{account_id}/invoices'
|
|
180
|
+
# client.get_invoices
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The same `{name}` form works inside `query:` and `body:` strings. A string that is exactly `'{name}'` passes the value through unchanged, so numbers, arrays and hashes keep their type. Placeholders within text are filled in as strings, and every one is used:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
route :search_issues, '/issues', query: { q: 'author:{username} state:{state}' }
|
|
187
|
+
route :create_report, '/reports', body: { title: 'Report for {username}', limit: '{page_size}' }
|
|
188
|
+
# q=author:octocat state:open {"title":"Report for octocat","limit":50}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`query:` and `body:` are compiled into the generated method when the class loads, so their fixed values must be strings, numbers, booleans, `nil`, hashes or arrays. Other objects (`Time`, `Date`, `BigDecimal`, `Range`, ...) raise an `ArgumentError` naming the route; pass them as an argument or a `'{method}'` placeholder, or write them as a string.
|
|
192
|
+
|
|
193
|
+
Any `{identifier}` in these strings is a placeholder; other text, including quotes and `#{...}`, is sent literally. Top-level String bodies (`body: '...'`) are always sent as is.
|
|
194
|
+
|
|
195
|
+
Path values, from arguments and `{name}` alike, are percent-encoded so each stays a single segment: `get_file(name: 'a/b c')` requests `/files/a%2Fb%20c`. Only RFC 3986 unreserved characters (`A-Z a-z 0-9 - . _ ~`) are left as is. To change this, override `escape_path`:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
# Allow '/' in values, e.g. for nested object keys
|
|
199
|
+
def escape_path(value)
|
|
200
|
+
value.to_s.split('/').map { |part| ERB::Util.url_encode(part) }.join('/')
|
|
201
|
+
end
|
|
140
202
|
```
|
|
141
203
|
|
|
142
204
|
**2. Query Parameters:**
|
|
@@ -182,6 +244,8 @@ class MyApiClient
|
|
|
182
244
|
end
|
|
183
245
|
```
|
|
184
246
|
|
|
247
|
+
A symbol or block given to `header` or `query_param` is evaluated on the client for every request. Values passed when calling a route, including per-request `headers:` and `query:`, are always sent as given, so `client.list_items(sort: :asc)` sends `sort=asc`. Header values are sent as strings; setting one to `nil` for a request leaves it out.
|
|
248
|
+
|
|
185
249
|
### Request Body Formats
|
|
186
250
|
|
|
187
251
|
Configure how request bodies are serialized:
|
|
@@ -193,7 +257,7 @@ class MyApiClient
|
|
|
193
257
|
# Default: JSON (using to_json)
|
|
194
258
|
body_builder :to_json
|
|
195
259
|
|
|
196
|
-
# URL-encoded form data (using to_query)
|
|
260
|
+
# URL-encoded form data (using to_query, requires ActiveSupport)
|
|
197
261
|
body_builder :to_query
|
|
198
262
|
|
|
199
263
|
# Custom query params builder (no ActiveSupport dependency)
|
|
@@ -213,6 +277,8 @@ class MyApiClient
|
|
|
213
277
|
end
|
|
214
278
|
```
|
|
215
279
|
|
|
280
|
+
String bodies are sent as is. Query strings are built the same way with `query_builder`, which accepts `:to_query`, `:query_params`, a method name or a block. It defaults to `:to_query` when ActiveSupport is loaded and to `:query_params` otherwise.
|
|
281
|
+
|
|
216
282
|
### Nested Routing (Sections)
|
|
217
283
|
|
|
218
284
|
Organize complex APIs with nested routes:
|
|
@@ -222,12 +288,17 @@ class MyApiClient
|
|
|
222
288
|
include ClientApiBuilder::Router
|
|
223
289
|
|
|
224
290
|
base_url 'https://api.example.com'
|
|
225
|
-
header 'Authorization', :
|
|
291
|
+
header 'Authorization', :authorization
|
|
226
292
|
|
|
227
293
|
attr_accessor :auth_token
|
|
228
294
|
|
|
295
|
+
def authorization
|
|
296
|
+
"Bearer #{auth_token}"
|
|
297
|
+
end
|
|
298
|
+
|
|
229
299
|
section :users do
|
|
230
300
|
base_url 'https://api.example.com/v2' # Override base URL
|
|
301
|
+
header 'Authorization', :authorization
|
|
231
302
|
|
|
232
303
|
route :list, '/users'
|
|
233
304
|
route :get, '/users/:id'
|
|
@@ -235,6 +306,8 @@ class MyApiClient
|
|
|
235
306
|
end
|
|
236
307
|
|
|
237
308
|
section :posts do
|
|
309
|
+
header 'Authorization', :authorization
|
|
310
|
+
|
|
238
311
|
route :list, '/posts'
|
|
239
312
|
route :get, '/posts/:id'
|
|
240
313
|
end
|
|
@@ -249,6 +322,8 @@ user = client.users.get(id: 123)
|
|
|
249
322
|
posts = client.posts.list
|
|
250
323
|
```
|
|
251
324
|
|
|
325
|
+
A section is its own router class. It uses the parent's `base_url` unless it sets one, but headers, query params, connection options, retries and builders are not inherited, so declare the ones it needs inside the section. Symbol and block values given to `header` and `query_param`, `{name}` path values, and response blocks are evaluated on the root client, so they can use its methods and state.
|
|
326
|
+
|
|
252
327
|
### Connection Options
|
|
253
328
|
|
|
254
329
|
Configure connection settings:
|
|
@@ -268,6 +343,8 @@ class MyApiClient
|
|
|
268
343
|
end
|
|
269
344
|
```
|
|
270
345
|
|
|
346
|
+
Any `Net::HTTP.start` option can be set this way. Your options are applied over the secure HTTPS defaults, so setting `verify_mode` yourself replaces `VERIFY_PEER`.
|
|
347
|
+
|
|
271
348
|
### Retry Configuration
|
|
272
349
|
|
|
273
350
|
Configure automatic retries for transient failures:
|
|
@@ -278,12 +355,14 @@ class MyApiClient
|
|
|
278
355
|
|
|
279
356
|
base_url 'https://api.example.com'
|
|
280
357
|
|
|
281
|
-
#
|
|
358
|
+
# Make up to 3 attempts in total, waiting 0.5 seconds between them
|
|
282
359
|
configure_retries 3, 0.5
|
|
283
360
|
end
|
|
284
361
|
```
|
|
285
362
|
|
|
286
|
-
|
|
363
|
+
The first argument is the total number of attempts, not extra retries. The default is 1, so requests are not retried unless you configure it. `retries:` and `sleep:` can also be passed per request.
|
|
364
|
+
|
|
365
|
+
Only these network errors are retried by default:
|
|
287
366
|
- `Net::OpenTimeout`, `Net::ReadTimeout`
|
|
288
367
|
- `Errno::ECONNRESET`, `Errno::ECONNREFUSED`, `Errno::ETIMEDOUT`
|
|
289
368
|
- `SocketError`, `EOFError`
|
|
@@ -345,6 +424,10 @@ client.process_stream do |response, chunk|
|
|
|
345
424
|
end
|
|
346
425
|
```
|
|
347
426
|
|
|
427
|
+
Streaming routes return the `Net::HTTPResponse`. Files are written in `wb` mode by default; pass `connection_options: { file_mode: 'ab' }` to append.
|
|
428
|
+
|
|
429
|
+
The status is checked against the route's expected response codes before any of the body is streamed. An error response raises `UnexpectedResponse` with the error body in `e.response.body`, and nothing is written to the file, IO or block; an existing file is left untouched.
|
|
430
|
+
|
|
348
431
|
### Response Handling
|
|
349
432
|
|
|
350
433
|
Customize how responses are processed:
|
|
@@ -370,11 +453,18 @@ class MyApiClient
|
|
|
370
453
|
data
|
|
371
454
|
end
|
|
372
455
|
end
|
|
456
|
+
|
|
457
|
+
# A block passed to the call replaces the route's block
|
|
458
|
+
client.get_user(id: 1) { |data| data['name'] }
|
|
373
459
|
```
|
|
374
460
|
|
|
461
|
+
Blocks run on the client, so they can call its methods and set its state. Empty response bodies return `nil`.
|
|
462
|
+
|
|
463
|
+
Redefining a route, in the same class or a subclass, replaces the whole definition including its block; without a block it goes back to the default handling. Pass the block again to keep it.
|
|
464
|
+
|
|
375
465
|
### Error Handling
|
|
376
466
|
|
|
377
|
-
|
|
467
|
+
`ClientApiBuilder::UnexpectedResponse` is raised when the status code isn't expected (any non-2xx by default, or anything outside `expected_response_code(s)`) and when a response body isn't valid JSON. It carries the response:
|
|
378
468
|
|
|
379
469
|
```ruby
|
|
380
470
|
begin
|
|
@@ -410,22 +500,22 @@ puts client.total_request_time # Time in seconds
|
|
|
410
500
|
puts client.request_attempts # Number of attempts (including retries)
|
|
411
501
|
```
|
|
412
502
|
|
|
503
|
+
These describe the latest attempt. If it failed, `response` is `nil` when no response arrived, and `request_options` is `nil` when the request couldn't be built.
|
|
504
|
+
|
|
413
505
|
### ActiveSupport Integration
|
|
414
506
|
|
|
415
|
-
When ActiveSupport is
|
|
507
|
+
When ActiveSupport is loaded before your client class includes `ClientApiBuilder::Router`, every request is instrumented as a `client_api_builder.request` event:
|
|
416
508
|
|
|
417
509
|
```ruby
|
|
418
|
-
# Set up logging
|
|
419
|
-
ClientApiBuilder.logger = Logger.new(STDOUT)
|
|
420
|
-
|
|
421
510
|
# Subscribe to request events
|
|
422
511
|
ActiveSupport::Notifications.subscribe('client_api_builder.request') do |*args|
|
|
423
512
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
424
513
|
client = event.payload[:client]
|
|
425
514
|
|
|
426
|
-
puts "#{client.request_options
|
|
515
|
+
puts "#{client.request_options&.dig(:method)} #{client.request_options&.dig(:uri)}"
|
|
427
516
|
puts "Status: #{client.response&.code}"
|
|
428
517
|
puts "Duration: #{event.duration.round(2)}ms"
|
|
518
|
+
puts "Failed: #{event.payload[:exception_object].inspect}" if event.payload[:exception]
|
|
429
519
|
end
|
|
430
520
|
|
|
431
521
|
# Or use the built-in log subscriber
|
|
@@ -433,9 +523,22 @@ subscriber = ClientApiBuilder::ActiveSupportLogSubscriber.new(Rails.logger)
|
|
|
433
523
|
subscriber.subscribe!
|
|
434
524
|
```
|
|
435
525
|
|
|
526
|
+
An event fires for every attempt, including failed ones. When an attempt raises, the payload also holds `:exception` (`[class name, message]`) and `:exception_object`, following the usual ActiveSupport convention, and `request_options` or `response` may be `nil`. The built-in subscriber logs lines like:
|
|
527
|
+
|
|
528
|
+
```
|
|
529
|
+
GET https://api.example.com/users/123[200] took 45ms
|
|
530
|
+
GET https://api.example.com/users/123[UNKNOWN] took 5003ms (Net::ReadTimeout: Net::ReadTimeout)
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Separately, `ClientApiBuilder.logger` receives every exception raised during a request attempt, including ones that are retried:
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
ClientApiBuilder.logger = Logger.new($stdout)
|
|
537
|
+
```
|
|
538
|
+
|
|
436
539
|
#### Production Logging
|
|
437
540
|
|
|
438
|
-
|
|
541
|
+
The built-in log subscriber already leaves out query strings, which may hold credentials. To customize the format, subscribe directly:
|
|
439
542
|
|
|
440
543
|
```ruby
|
|
441
544
|
ActiveSupport::Notifications.subscribe('client_api_builder.request') do |_, start_time, end_time, _, payload|
|
|
@@ -461,11 +564,11 @@ Client API Builder includes several security features enabled by default:
|
|
|
461
564
|
|
|
462
565
|
### SSL/TLS Verification
|
|
463
566
|
|
|
464
|
-
|
|
567
|
+
HTTPS connections verify SSL certificates using `OpenSSL::SSL::VERIFY_PEER` and default to a 30 second open timeout and 60 second read timeout. Plain HTTP connections use Net::HTTP's own defaults.
|
|
465
568
|
|
|
466
569
|
### SSRF Protection
|
|
467
570
|
|
|
468
|
-
Base URLs
|
|
571
|
+
Base URLs must use the `http` or `https` scheme and include a host, preventing Server-Side Request Forgery attacks:
|
|
469
572
|
|
|
470
573
|
```ruby
|
|
471
574
|
class MyApiClient
|
|
@@ -478,16 +581,27 @@ class MyApiClient
|
|
|
478
581
|
end
|
|
479
582
|
```
|
|
480
583
|
|
|
584
|
+
The URL actually used is checked again on every request, so a `base_url` method defined on the client (for example, a per-tenant URL) gets the same check. A client with no base URL at all raises `ArgumentError: no base_url configured for MyApiClient` instead of failing inside Net::HTTP.
|
|
585
|
+
|
|
586
|
+
### Path Value Encoding
|
|
587
|
+
|
|
588
|
+
Values inserted into a route's path are percent-encoded, so input such as `../admin` or `a/b?x=1` can't add path segments or a query string to the request.
|
|
589
|
+
|
|
481
590
|
### Path Traversal Protection
|
|
482
591
|
|
|
483
|
-
File streaming
|
|
592
|
+
File streaming rejects a path with a `..` segment or a null byte, so a file name built from untrusted input can't climb out of the directory you put it in:
|
|
484
593
|
|
|
485
594
|
```ruby
|
|
486
|
-
# These
|
|
487
|
-
client.download_file(id: 1, file: '
|
|
595
|
+
# These raise ArgumentError
|
|
596
|
+
client.download_file(id: 1, file: "/downloads/#{'../etc/passwd'}")
|
|
488
597
|
client.download_file(id: 1, file: "/tmp/file\0.txt")
|
|
598
|
+
|
|
599
|
+
# Names that only contain dots are fine
|
|
600
|
+
client.download_file(id: 1, file: '/downloads/report..v2.csv')
|
|
489
601
|
```
|
|
490
602
|
|
|
603
|
+
Absolute paths and symlinks are not restricted, since only your code knows where files may go. If untrusted input can supply the whole path, validate it before passing it as `file:`.
|
|
604
|
+
|
|
491
605
|
### Safe File Modes
|
|
492
606
|
|
|
493
607
|
Only safe file modes are allowed for streaming to files: `w`, `wb`, `a`, `ab`, `w+`, `wb+`, `a+`, `ab+`.
|
|
@@ -522,12 +636,12 @@ end
|
|
|
522
636
|
| Method | Description |
|
|
523
637
|
|--------|-------------|
|
|
524
638
|
| `base_url(url)` | Set the base URL for all requests |
|
|
525
|
-
| `header(name, value)` | Add a header to all requests |
|
|
639
|
+
| `header(name, value = nil, &block)` | Add a header to all requests (value, method name symbol, or block) |
|
|
526
640
|
| `body_builder(builder)` | Configure request body serialization |
|
|
527
641
|
| `query_builder(builder)` | Configure query string serialization |
|
|
528
|
-
| `query_param(name, value)` | Add a query parameter to all requests |
|
|
642
|
+
| `query_param(name, value = nil, &block)` | Add a query parameter to all requests (value, method name symbol, or block) |
|
|
529
643
|
| `connection_option(name, value)` | Set Net::HTTP connection options |
|
|
530
|
-
| `configure_retries(
|
|
644
|
+
| `configure_retries(max_attempts, sleep = 0.05)` | Configure retry behavior |
|
|
531
645
|
| `route(name, path, options)` | Define an API endpoint |
|
|
532
646
|
| `section(name, options, &block)` | Define nested routes |
|
|
533
647
|
| `namespace(path, &block)` | Add path prefix to routes in block |
|
|
@@ -541,11 +655,27 @@ end
|
|
|
541
655
|
| `total_request_time` | Duration of last request in seconds |
|
|
542
656
|
| `request_attempts` | Number of attempts for last request |
|
|
543
657
|
| `root_router` | Returns the root router (for nested routers) |
|
|
658
|
+
| `base_url` | Base URL used for requests |
|
|
659
|
+
|
|
660
|
+
### Overridable Hooks
|
|
661
|
+
|
|
662
|
+
Define these in your client to change default behavior:
|
|
663
|
+
|
|
664
|
+
| Method | Default |
|
|
665
|
+
|--------|---------|
|
|
666
|
+
| `retry_request?(exception, options)` | `true` for the network errors listed under Retry Configuration |
|
|
667
|
+
| `escape_path(value)` | Percent-encodes path values (`ERB::Util.url_encode`) |
|
|
668
|
+
| `parse_response(response, options)` | Parses the body as JSON, `nil` when empty |
|
|
669
|
+
| `handle_response(response, options, &block)` | Applies `return:`, parsing and the response block |
|
|
670
|
+
| `expected_response_code!(response, codes, options)` | Raises `UnexpectedResponse` for unexpected codes |
|
|
671
|
+
| `get_retry_request_max_retries(options)` | `retries:` option, then `configure_retries`, then 1 |
|
|
672
|
+
| `get_retry_request_sleep_time(exception, options)` | `sleep:` option, then `configure_retries`, then 0.05 |
|
|
544
673
|
|
|
545
674
|
## Requirements
|
|
546
675
|
|
|
547
676
|
- Ruby 3.2+
|
|
548
677
|
- `inheritance-helper` gem (>= 0.2.5)
|
|
678
|
+
- `activesupport` (optional) for `to_query` builders and instrumentation
|
|
549
679
|
|
|
550
680
|
## Contributing
|
|
551
681
|
|
|
@@ -554,7 +684,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/dougyo
|
|
|
554
684
|
1. Fork the repository
|
|
555
685
|
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
|
556
686
|
3. Write tests for your changes
|
|
557
|
-
4. Ensure all tests pass (`bundle exec rspec`)
|
|
687
|
+
4. Ensure all tests pass with full line and branch coverage (`CI=true bundle exec rspec`)
|
|
558
688
|
5. Ensure code style compliance (`bundle exec rubocop`)
|
|
559
689
|
6. Commit your changes using [conventional commits](https://www.conventionalcommits.org/) (`git commit -am 'feat(router): add my feature'`); release notes and version bumps are generated from them
|
|
560
690
|
7. Push to the branch (`git push origin feature/my-feature`)
|
data/lib/client-api-builder.rb
CHANGED
|
@@ -22,6 +22,7 @@ module ClientApiBuilder
|
|
|
22
22
|
autoload :ActiveSupportLogSubscriber, 'client_api_builder/active_support_log_subscriber'
|
|
23
23
|
autoload :NestedRouter, 'client_api_builder/nested_router'
|
|
24
24
|
autoload :QueryParams, 'client_api_builder/query_params'
|
|
25
|
+
autoload :RouteValueValidator, 'client_api_builder/route_value_validator'
|
|
25
26
|
autoload :Router, 'client_api_builder/router'
|
|
26
27
|
autoload :Section, 'client_api_builder/section'
|
|
27
28
|
|
|
@@ -17,14 +17,27 @@ module ClientApiBuilder
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# request_options is nil when the request failed before it was built,
|
|
21
|
+
# and response is nil when no response was received.
|
|
22
|
+
# Failed requests end with the exception, e.g. "(Net::OpenTimeout: execution expired)".
|
|
20
23
|
def generate_log_message(event)
|
|
21
24
|
client = event.payload[:client]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
response = client.response
|
|
25
|
-
response_code = response ? response.code : 'UNKNOWN'
|
|
25
|
+
request_options = client.request_options
|
|
26
|
+
response_code = client.response ? client.response.code : 'UNKNOWN'
|
|
26
27
|
|
|
27
|
-
"#{
|
|
28
|
+
message = "#{request_description(request_options)}[#{response_code}] took #{event.duration.to_i}ms"
|
|
29
|
+
exception = event.payload[:exception]
|
|
30
|
+
exception ? "#{message} (#{exception.join(': ')})" : message
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def request_description(request_options)
|
|
36
|
+
return '[request not built]' unless request_options
|
|
37
|
+
|
|
38
|
+
method = request_options[:method].to_s.upcase
|
|
39
|
+
uri = request_options[:uri]
|
|
40
|
+
uri ? "#{method} #{uri.scheme}://#{uri.host}#{uri.path}" : "#{method} [no URI]"
|
|
28
41
|
end
|
|
29
42
|
end
|
|
30
43
|
end
|
|
@@ -5,21 +5,11 @@ require 'active_support'
|
|
|
5
5
|
# Purpose is to change the instrument_request to use ActiveSupport::Notifications.instrument
|
|
6
6
|
module ClientApiBuilder
|
|
7
7
|
module ActiveSupportNotifications
|
|
8
|
-
|
|
8
|
+
# When the request raises, ActiveSupport adds :exception and :exception_object to the
|
|
9
|
+
# event payload, notifies subscribers, and re-raises the original exception.
|
|
10
|
+
def instrument_request(&)
|
|
9
11
|
start_time = Time.now
|
|
10
|
-
|
|
11
|
-
result = nil
|
|
12
|
-
ActiveSupport::Notifications.instrument('client_api_builder.request', client: self) do
|
|
13
|
-
result = yield
|
|
14
|
-
rescue StandardError => e
|
|
15
|
-
# Use StandardError instead of Exception to allow SystemExit, Interrupt, etc. to propagate
|
|
16
|
-
error = e
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Re-raise with original backtrace preserved
|
|
20
|
-
raise(error, error.message, error.backtrace) if error
|
|
21
|
-
|
|
22
|
-
result
|
|
12
|
+
ActiveSupport::Notifications.instrument('client_api_builder.request', client: self, &)
|
|
23
13
|
ensure
|
|
24
14
|
@total_request_time = Time.now - start_time
|
|
25
15
|
end
|
|
@@ -50,46 +50,78 @@ module ClientApiBuilder
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
# validate_response, when given, is called with the response before its body is streamed
|
|
54
|
+
# and raises to reject it. A rejected body is read into response.body instead of being
|
|
55
|
+
# streamed, so the error can still show it.
|
|
56
|
+
def stream(method:, uri:, body:, headers:, connection_options:, validate_response: nil)
|
|
54
57
|
request(method: method, uri: uri, body: body, headers: headers,
|
|
55
58
|
connection_options: connection_options) do |response|
|
|
59
|
+
validate_streamed_response(response, validate_response) if validate_response
|
|
56
60
|
response.read_body do |chunk|
|
|
57
61
|
yield response, chunk
|
|
58
62
|
end
|
|
59
63
|
end
|
|
60
64
|
end
|
|
61
65
|
|
|
62
|
-
def stream_to_io(method:, uri:, body:, headers:, connection_options:, io:)
|
|
66
|
+
def stream_to_io(method:, uri:, body:, headers:, connection_options:, io:, validate_response: nil)
|
|
63
67
|
stream(method: method, uri: uri, body: body, headers: headers,
|
|
64
|
-
connection_options: connection_options) do |_, chunk|
|
|
68
|
+
connection_options: connection_options, validate_response: validate_response) do |_, chunk|
|
|
65
69
|
io.write chunk
|
|
66
70
|
end
|
|
67
71
|
end
|
|
68
72
|
|
|
69
|
-
|
|
73
|
+
# The file is opened only once the response has passed validate_response, so a rejected
|
|
74
|
+
# response never creates, truncates or appends to it.
|
|
75
|
+
def stream_to_file(method:, uri:, body:, headers:, connection_options:, file:, validate_response: nil)
|
|
70
76
|
# Use dup to avoid mutating the original hash
|
|
71
77
|
opts = connection_options.dup
|
|
72
|
-
mode = opts.delete(:file_mode)
|
|
73
|
-
|
|
74
|
-
# Validate file mode - use whitelist approach
|
|
75
|
-
mode = if mode.nil?
|
|
76
|
-
'wb'
|
|
77
|
-
elsif ALLOWED_FILE_MODES.include?(mode.to_s)
|
|
78
|
-
mode.to_s
|
|
79
|
-
else
|
|
80
|
-
raise ArgumentError,
|
|
81
|
-
"Invalid file mode: #{mode.inspect}. Allowed modes: #{ALLOWED_FILE_MODES.join(', ')}"
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Validate file path - expand to absolute path and check for path traversal
|
|
85
|
-
expanded_path = File.expand_path(file)
|
|
86
|
-
if file.to_s.include?('..') || expanded_path.include?("\0")
|
|
87
|
-
raise ArgumentError, 'Invalid file path: potential path traversal detected'
|
|
88
|
-
end
|
|
78
|
+
mode = stream_file_mode(opts.delete(:file_mode))
|
|
79
|
+
path = stream_file_path(file)
|
|
89
80
|
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
io = nil
|
|
82
|
+
open_file = lambda do |response|
|
|
83
|
+
validate_response&.call(response)
|
|
84
|
+
io = File.open(path, mode) # rubocop:disable Style/FileOpen -- closed in ensure
|
|
85
|
+
end
|
|
86
|
+
stream(method: method, uri: uri, body: body, headers: headers,
|
|
87
|
+
connection_options: opts, validate_response: open_file) do |_, chunk|
|
|
88
|
+
io.write chunk
|
|
92
89
|
end
|
|
90
|
+
ensure
|
|
91
|
+
io&.close
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def validate_streamed_response(response, validate_response)
|
|
97
|
+
validate_response.call(response)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
response.read_body
|
|
100
|
+
raise
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Validate file mode - use whitelist approach
|
|
104
|
+
def stream_file_mode(mode)
|
|
105
|
+
return 'wb' if mode.nil?
|
|
106
|
+
return mode.to_s if ALLOWED_FILE_MODES.include?(mode.to_s)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Invalid file mode: #{mode.inspect}. Allowed modes: #{ALLOWED_FILE_MODES.join(', ')}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Rejects null bytes and any '..' path segment, so a name built from untrusted input
|
|
112
|
+
# can't climb out of the directory the caller put it in. Names merely containing '..'
|
|
113
|
+
# (report..v2.csv) are fine. Absolute paths are allowed: the caller decides where files go.
|
|
114
|
+
def stream_file_path(file)
|
|
115
|
+
path = file.to_s
|
|
116
|
+
raise ArgumentError, 'Invalid file path: contains a null byte' if path.include?("\0")
|
|
117
|
+
raise ArgumentError, 'Invalid file path: potential path traversal detected' if parent_segment?(path)
|
|
118
|
+
|
|
119
|
+
File.expand_path(path)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Splits on both separators so the rule is the same on every platform
|
|
123
|
+
def parent_segment?(path)
|
|
124
|
+
path.split(%r{[/\\]}).include?('..')
|
|
93
125
|
end
|
|
94
126
|
end
|
|
95
127
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
# A route's query: and body: values are compiled into the source of the generated method,
|
|
5
|
+
# so only values that value_to_code can write back as equal Ruby literals are allowed.
|
|
6
|
+
# Anything computed per request belongs in a symbol argument or a '{method}' placeholder.
|
|
7
|
+
module RouteValueValidator
|
|
8
|
+
LITERAL_CLASSES = [String, Integer, TrueClass, FalseClass, NilClass].freeze
|
|
9
|
+
ARGUMENT_NAME = /\A[a-z_][a-z0-9_]*\z/i
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Raises ArgumentError naming the route for the first value that can't be compiled
|
|
14
|
+
def validate!(route_name, location, value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
value.each do |key, item|
|
|
18
|
+
validate_key!(route_name, location, key)
|
|
19
|
+
validate!(route_name, location, item)
|
|
20
|
+
end
|
|
21
|
+
when Array
|
|
22
|
+
value.each { |item| validate!(route_name, location, item) }
|
|
23
|
+
when Symbol
|
|
24
|
+
validate_argument_name!(route_name, location, value)
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, unsupported_value_message(route_name, location, value) unless literal?(value)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_key!(route_name, location, key)
|
|
31
|
+
return if key.is_a?(Symbol) || literal?(key)
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, unsupported_value_message(route_name, location, key)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Symbol values become keyword arguments of the generated method
|
|
37
|
+
def validate_argument_name!(route_name, location, name)
|
|
38
|
+
return if name.to_s.match?(ARGUMENT_NAME)
|
|
39
|
+
|
|
40
|
+
raise ArgumentError,
|
|
41
|
+
"route #{route_name.inspect}: #{location} argument #{name.inspect} is not a valid argument name"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def literal?(value)
|
|
45
|
+
LITERAL_CLASSES.any? { |klass| value.is_a?(klass) } || (value.is_a?(Float) && value.finite?)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def unsupported_value_message(route_name, location, value)
|
|
49
|
+
"route #{route_name.inspect}: #{location} value #{value.inspect} (#{value.class}) can't be written into " \
|
|
50
|
+
"the generated method; use a String, number, boolean, nil, Hash or Array, or a '{method}' placeholder " \
|
|
51
|
+
'to compute it per request'
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'erb'
|
|
3
4
|
require 'inheritance-helper'
|
|
4
5
|
require 'json'
|
|
5
6
|
|
|
@@ -24,17 +25,31 @@ module ClientApiBuilder
|
|
|
24
25
|
# Allowed URL schemes for base_url to prevent SSRF attacks
|
|
25
26
|
ALLOWED_URL_SCHEMES = %w[http https].freeze
|
|
26
27
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
# Ruby source that value_to_code inserts verbatim into a generated route method
|
|
29
|
+
CodeSnippet = Data.define(:code)
|
|
30
|
+
|
|
31
|
+
# '{name}' in a query/body string: a route argument with that name, or else the client's method
|
|
32
|
+
PLACEHOLDER = /\{([a-z0-9_]+)\}/i
|
|
33
|
+
WHOLE_PLACEHOLDER = /\A\{([a-z0-9_]+)\}\z/i
|
|
34
|
+
|
|
35
|
+
# ':name' in a route path is an argument unless the colon follows a letter, digit, '_' or '}',
|
|
36
|
+
# so items:batchGet, {name}:cancel and 12:30 stay literal
|
|
37
|
+
PATH_PARAMETER = /(?<![a-z0-9_}]):([a-z_][a-z0-9_]*)/i
|
|
38
|
+
|
|
39
|
+
# Deep duplicates hashes and arrays (at any depth) to prevent shared mutable state.
|
|
40
|
+
# Other values are returned as is.
|
|
41
|
+
def deep_dup(value)
|
|
42
|
+
case value
|
|
43
|
+
when Hash then value.transform_values { |v| deep_dup(v) }
|
|
44
|
+
when Array then value.map { |v| deep_dup(v) }
|
|
45
|
+
else value
|
|
35
46
|
end
|
|
36
47
|
end
|
|
37
48
|
|
|
49
|
+
def deep_dup_hash(hash)
|
|
50
|
+
deep_dup(hash)
|
|
51
|
+
end
|
|
52
|
+
|
|
38
53
|
def default_options
|
|
39
54
|
{
|
|
40
55
|
base_url: nil,
|
|
@@ -49,7 +64,7 @@ module ClientApiBuilder
|
|
|
49
64
|
}.freeze
|
|
50
65
|
end
|
|
51
66
|
|
|
52
|
-
# tracks the proc used to handle responses
|
|
67
|
+
# tracks the proc used to handle responses; nil clears it
|
|
53
68
|
def add_response_proc(method_name, proc)
|
|
54
69
|
response_procs = deep_dup_hash(default_options[:response_procs])
|
|
55
70
|
response_procs[method_name] = proc
|
|
@@ -73,10 +88,11 @@ module ClientApiBuilder
|
|
|
73
88
|
# Validates that base_url uses an allowed scheme
|
|
74
89
|
def validate_base_url!(url)
|
|
75
90
|
uri = URI.parse(url.to_s)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
unless ALLOWED_URL_SCHEMES.include?(uri.scheme&.downcase)
|
|
92
|
+
allowed = ALLOWED_URL_SCHEMES.join(', ')
|
|
93
|
+
raise ArgumentError, "Invalid base_url scheme: #{uri.scheme.inspect}. Allowed: #{allowed}"
|
|
94
|
+
end
|
|
95
|
+
raise ArgumentError, "Invalid base_url: #{url.to_s.inspect} has no host" if uri.host.to_s.empty?
|
|
80
96
|
rescue URI::InvalidURIError => e
|
|
81
97
|
raise ArgumentError, "Invalid base_url: #{e.message}"
|
|
82
98
|
end
|
|
@@ -169,15 +185,17 @@ module ClientApiBuilder
|
|
|
169
185
|
end
|
|
170
186
|
end
|
|
171
187
|
|
|
188
|
+
# The verb must be the whole name or be followed by '_', so create_user is a POST
|
|
189
|
+
# but names such as posts or deleted_users stay GET.
|
|
172
190
|
def auto_detect_http_method(method_name)
|
|
173
191
|
case method_name.to_s
|
|
174
|
-
when
|
|
192
|
+
when /\A(?:post|create|add|insert)(?:_|\z)/i
|
|
175
193
|
:post
|
|
176
|
-
when
|
|
194
|
+
when /\A(?:put|update|modify|change)(?:_|\z)/i
|
|
177
195
|
:put
|
|
178
|
-
when
|
|
196
|
+
when /\Apatch(?:_|\z)/i
|
|
179
197
|
:patch
|
|
180
|
-
when
|
|
198
|
+
when /\A(?:delete|remove|destroy)(?:_|\z)/i
|
|
181
199
|
:delete
|
|
182
200
|
else
|
|
183
201
|
:get
|
|
@@ -191,48 +209,56 @@ module ClientApiBuilder
|
|
|
191
209
|
REQUIRED_BODY_HTTP_METHODS.include?(http_method)
|
|
192
210
|
end
|
|
193
211
|
|
|
212
|
+
# Replaces argument symbols and '{name}' strings in a query/body hash, in place, with
|
|
213
|
+
# CodeSnippets; returns the argument names found
|
|
194
214
|
def get_hash_arguments(hsh)
|
|
195
215
|
arguments = []
|
|
196
|
-
hsh.each
|
|
197
|
-
case v
|
|
198
|
-
when Symbol
|
|
199
|
-
hsh[k] = "__||#{v}||__"
|
|
200
|
-
arguments << v
|
|
201
|
-
when Hash
|
|
202
|
-
arguments += get_hash_arguments(v)
|
|
203
|
-
when Array
|
|
204
|
-
arguments += get_array_arguments(v)
|
|
205
|
-
when String
|
|
206
|
-
# Use match with block form to avoid thread-unsafe $1 global variable
|
|
207
|
-
if (match = v.match(/\{([a-z0-9_]+)\}/i))
|
|
208
|
-
hsh[k] = "__||#{match[1]}||__"
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
end
|
|
216
|
+
hsh.each { |key, value| hsh[key] = argument_code(value, arguments) }
|
|
212
217
|
arguments
|
|
213
218
|
end
|
|
214
219
|
|
|
220
|
+
# Same as get_hash_arguments, for an array
|
|
215
221
|
def get_array_arguments(list)
|
|
216
222
|
arguments = []
|
|
217
|
-
list.each_with_index
|
|
218
|
-
case v
|
|
219
|
-
when Symbol
|
|
220
|
-
list[idx] = "__||#{v}||__"
|
|
221
|
-
arguments << v
|
|
222
|
-
when Hash
|
|
223
|
-
arguments += get_hash_arguments(v)
|
|
224
|
-
when Array
|
|
225
|
-
arguments += get_array_arguments(v)
|
|
226
|
-
when String
|
|
227
|
-
# Use match with block form to avoid thread-unsafe $1 global variable
|
|
228
|
-
if (match = v.match(/\{([a-z0-9_]+)\}/i))
|
|
229
|
-
list[idx] = "__||#{match[1]}||__"
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
end
|
|
223
|
+
list.each_with_index { |value, idx| list[idx] = argument_code(value, arguments) }
|
|
233
224
|
arguments
|
|
234
225
|
end
|
|
235
226
|
|
|
227
|
+
def argument_code(value, arguments)
|
|
228
|
+
case value
|
|
229
|
+
when Symbol
|
|
230
|
+
arguments << value
|
|
231
|
+
CodeSnippet.new(value.to_s)
|
|
232
|
+
when Hash
|
|
233
|
+
arguments.concat(get_hash_arguments(value))
|
|
234
|
+
value
|
|
235
|
+
when Array
|
|
236
|
+
arguments.concat(get_array_arguments(value))
|
|
237
|
+
value
|
|
238
|
+
when String
|
|
239
|
+
string_template_code(value)
|
|
240
|
+
else
|
|
241
|
+
value
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A string that is exactly '{name}' passes the value through unchanged (keeping its type);
|
|
246
|
+
# placeholders within text are interpolated into the string. Strings without placeholders
|
|
247
|
+
# are returned as is.
|
|
248
|
+
def string_template_code(str)
|
|
249
|
+
return str unless str.match?(PLACEHOLDER)
|
|
250
|
+
|
|
251
|
+
whole = str.match(WHOLE_PLACEHOLDER)
|
|
252
|
+
return CodeSnippet.new(whole[1]) if whole
|
|
253
|
+
|
|
254
|
+
parts = str.split(/(\{[a-z0-9_]+\})/i).map do |part|
|
|
255
|
+
placeholder = part.match(WHOLE_PLACEHOLDER)
|
|
256
|
+
# inspect escapes quotes, backslashes and '#{' so the text stays literal
|
|
257
|
+
placeholder ? "\#{#{placeholder[1]}}" : part.inspect[1..-2]
|
|
258
|
+
end
|
|
259
|
+
CodeSnippet.new("\"#{parts.join}\"")
|
|
260
|
+
end
|
|
261
|
+
|
|
236
262
|
# returns a list of arguments to add to the route method
|
|
237
263
|
def get_arguments(value)
|
|
238
264
|
case value
|
|
@@ -274,7 +300,7 @@ module ClientApiBuilder
|
|
|
274
300
|
end
|
|
275
301
|
|
|
276
302
|
path_arguments = []
|
|
277
|
-
path = path.gsub(
|
|
303
|
+
path = path.gsub(PATH_PARAMETER) do |_match|
|
|
278
304
|
param_name = Regexp.last_match(1)
|
|
279
305
|
path_arguments << param_name
|
|
280
306
|
"#\{escape_path(#{param_name})}"
|
|
@@ -292,7 +318,7 @@ module ClientApiBuilder
|
|
|
292
318
|
|
|
293
319
|
pairs = value.map do |k, v|
|
|
294
320
|
key_code = case k
|
|
295
|
-
when Symbol then "#{k}: "
|
|
321
|
+
when Symbol then k.match?(RouteValueValidator::ARGUMENT_NAME) ? "#{k}: " : "#{k.inspect} => "
|
|
296
322
|
when String then "#{k.inspect} => "
|
|
297
323
|
else "#{value_to_code(k)} => "
|
|
298
324
|
end
|
|
@@ -305,17 +331,19 @@ module ClientApiBuilder
|
|
|
305
331
|
'nil'
|
|
306
332
|
when TrueClass, FalseClass
|
|
307
333
|
value.to_s
|
|
334
|
+
when CodeSnippet
|
|
335
|
+
value.code
|
|
308
336
|
else
|
|
309
337
|
value.inspect
|
|
310
338
|
end
|
|
311
339
|
end
|
|
312
340
|
|
|
341
|
+
# get_arguments rewrites values in place, so work on a copy of the caller's query/body
|
|
313
342
|
def build_query_code(options)
|
|
314
343
|
if options[:query]
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
[str, query_arguments.map(&:to_s)]
|
|
344
|
+
query = deep_dup(options[:query])
|
|
345
|
+
query_arguments = get_arguments(query)
|
|
346
|
+
[value_to_code(query), query_arguments.map(&:to_s)]
|
|
319
347
|
else
|
|
320
348
|
['nil', []]
|
|
321
349
|
end
|
|
@@ -323,10 +351,9 @@ module ClientApiBuilder
|
|
|
323
351
|
|
|
324
352
|
def build_body_code(options, has_body_param)
|
|
325
353
|
if options[:body]
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
[str, body_arguments.map(&:to_s), false]
|
|
354
|
+
body = deep_dup(options[:body])
|
|
355
|
+
body_arguments = get_arguments(body)
|
|
356
|
+
[value_to_code(body), body_arguments.map(&:to_s), false]
|
|
330
357
|
else
|
|
331
358
|
[has_body_param ? 'body' : 'nil', [], has_body_param]
|
|
332
359
|
end
|
|
@@ -351,18 +378,26 @@ module ClientApiBuilder
|
|
|
351
378
|
args + ['**__options__', '&block']
|
|
352
379
|
end
|
|
353
380
|
|
|
354
|
-
def generate_request_call_code(options, stream_param)
|
|
381
|
+
def generate_request_call_code(options, stream_param, expected_response_codes)
|
|
355
382
|
code = " @request_options[:#{stream_param}] = #{stream_param}\n" if stream_param
|
|
356
383
|
code ||= ''
|
|
384
|
+
validator = stream_validator_code(expected_response_codes)
|
|
357
385
|
|
|
358
386
|
code + case options[:stream]
|
|
359
|
-
when true, :file then " @response = stream_to_file(**@request_options)\n"
|
|
360
|
-
when :io then " @response = stream_to_io(**@request_options)\n"
|
|
361
|
-
when :block then " @response = stream(**@request_options, &block)\n"
|
|
387
|
+
when true, :file then " @response = stream_to_file(**@request_options, #{validator})\n"
|
|
388
|
+
when :io then " @response = stream_to_io(**@request_options, #{validator})\n"
|
|
389
|
+
when :block then " @response = stream(**@request_options, #{validator}, &block)\n"
|
|
362
390
|
else " @response = request(**@request_options)\n"
|
|
363
391
|
end
|
|
364
392
|
end
|
|
365
393
|
|
|
394
|
+
# Streaming routes check the status before the body is streamed, using the same
|
|
395
|
+
# expected_response_code! as other routes, so error bodies never reach the file, IO or block
|
|
396
|
+
def stream_validator_code(expected_response_codes)
|
|
397
|
+
'validate_response: ->(response) { ' \
|
|
398
|
+
"expected_response_code!(response, #{expected_response_codes.inspect}, __options__) }"
|
|
399
|
+
end
|
|
400
|
+
|
|
366
401
|
def generate_response_handling_code(options)
|
|
367
402
|
if options[:stream] || options[:return] == :response
|
|
368
403
|
" @response\n"
|
|
@@ -379,6 +414,9 @@ module ClientApiBuilder
|
|
|
379
414
|
raise ArgumentError, "Invalid method name: #{method_name.inspect}"
|
|
380
415
|
end
|
|
381
416
|
|
|
417
|
+
RouteValueValidator.validate!(method_name, :query, options[:query])
|
|
418
|
+
RouteValueValidator.validate!(method_name, :body, options[:body])
|
|
419
|
+
|
|
382
420
|
http_method = options[:method] || auto_detect_http_method(method_name)
|
|
383
421
|
path, path_arguments = process_route_path(path)
|
|
384
422
|
has_body_param = options[:body].nil? && requires_body?(http_method, options)
|
|
@@ -412,7 +450,7 @@ module ClientApiBuilder
|
|
|
412
450
|
code += " __connection_options__ = build_connection_options(__options__)\n"
|
|
413
451
|
code += " @request_options = {method: #{ctx[:http_method].inspect}, uri: __uri__, body: __body__, " \
|
|
414
452
|
"headers: __headers__, connection_options: __connection_options__}\n"
|
|
415
|
-
code += generate_request_call_code(ctx[:options], ctx[:stream_param])
|
|
453
|
+
code += generate_request_call_code(ctx[:options], ctx[:stream_param], ctx[:expected_response_codes])
|
|
416
454
|
"#{code}end\n\n"
|
|
417
455
|
end
|
|
418
456
|
|
|
@@ -430,8 +468,10 @@ module ClientApiBuilder
|
|
|
430
468
|
"#{code}end\n"
|
|
431
469
|
end
|
|
432
470
|
|
|
471
|
+
# A route definition is complete: redefining a route without a block also clears the
|
|
472
|
+
# previous block, including one inherited from a parent class
|
|
433
473
|
def route(method_name, path, options = {}, &block)
|
|
434
|
-
add_response_proc(method_name, block)
|
|
474
|
+
add_response_proc(method_name, block)
|
|
435
475
|
|
|
436
476
|
class_eval generate_route_code(method_name, path, options), __FILE__, __LINE__
|
|
437
477
|
end
|
|
@@ -441,24 +481,12 @@ module ClientApiBuilder
|
|
|
441
481
|
self.class.base_url
|
|
442
482
|
end
|
|
443
483
|
|
|
484
|
+
# Class-level headers may be method names or blocks; per-request headers are used as given.
|
|
485
|
+
# Values are converted to strings, as Net::HTTP requires; nil values are left out of the request.
|
|
444
486
|
def build_headers(options)
|
|
445
|
-
headers = {}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
headers[name] =
|
|
449
|
-
if value.is_a?(Proc)
|
|
450
|
-
root_router.instance_eval(&value)
|
|
451
|
-
elsif value.is_a?(Symbol)
|
|
452
|
-
root_router.send(value)
|
|
453
|
-
else
|
|
454
|
-
value
|
|
455
|
-
end
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
self.class.default_headers.each(&add_header_proc)
|
|
459
|
-
options[:headers]&.each(&add_header_proc)
|
|
460
|
-
|
|
461
|
-
headers
|
|
487
|
+
headers = self.class.default_headers.transform_values { |value| resolve_config_value(value) }
|
|
488
|
+
headers.merge!(options[:headers]) if options[:headers]
|
|
489
|
+
headers.transform_values { |value| value&.to_s }
|
|
462
490
|
end
|
|
463
491
|
|
|
464
492
|
def build_connection_options(options)
|
|
@@ -469,27 +497,26 @@ module ClientApiBuilder
|
|
|
469
497
|
end
|
|
470
498
|
end
|
|
471
499
|
|
|
500
|
+
# Class-level query params may be method names or blocks; values from route arguments
|
|
501
|
+
# and per-request options are sent as given.
|
|
472
502
|
def build_query(query, options)
|
|
473
|
-
query_params = {}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
query_params[name] =
|
|
477
|
-
if value.is_a?(Proc)
|
|
478
|
-
root_router.instance_eval(&value)
|
|
479
|
-
elsif value.is_a?(Symbol)
|
|
480
|
-
root_router.send(value)
|
|
481
|
-
else
|
|
482
|
-
value
|
|
483
|
-
end
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
self.class.default_query_params.each(&add_query_param_proc)
|
|
487
|
-
query&.each(&add_query_param_proc)
|
|
488
|
-
options[:query]&.each(&add_query_param_proc)
|
|
503
|
+
query_params = self.class.default_query_params.transform_values { |value| resolve_config_value(value) }
|
|
504
|
+
query_params.merge!(query) if query
|
|
505
|
+
query_params.merge!(options[:query]) if options[:query]
|
|
489
506
|
|
|
490
507
|
query_params.empty? ? nil : self.class.build_query(self, query_params)
|
|
491
508
|
end
|
|
492
509
|
|
|
510
|
+
# Resolves a class-level header or query_param value: a Symbol calls that method and a
|
|
511
|
+
# Proc is evaluated, both on the root router; anything else is used as is.
|
|
512
|
+
def resolve_config_value(value)
|
|
513
|
+
case value
|
|
514
|
+
when Proc then root_router.instance_eval(&value)
|
|
515
|
+
when Symbol then root_router.send(value)
|
|
516
|
+
else value
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
493
520
|
def build_body(body, options)
|
|
494
521
|
body = options[:body] if options.key?(:body)
|
|
495
522
|
|
|
@@ -501,8 +528,7 @@ module ClientApiBuilder
|
|
|
501
528
|
|
|
502
529
|
def build_uri(path, query, options)
|
|
503
530
|
# Properly join base_url and path to handle missing/extra slashes
|
|
504
|
-
base =
|
|
505
|
-
base = base.chomp('/') if base.end_with?('/')
|
|
531
|
+
base = validated_base_url.chomp('/')
|
|
506
532
|
path = path.to_s
|
|
507
533
|
path = "/#{path}" unless path.start_with?('/')
|
|
508
534
|
|
|
@@ -511,6 +537,19 @@ module ClientApiBuilder
|
|
|
511
537
|
uri
|
|
512
538
|
end
|
|
513
539
|
|
|
540
|
+
# The base URL used for this request, checked every time so a base_url method defined on
|
|
541
|
+
# the client gets the same scheme and host check as the class-level base_url
|
|
542
|
+
def validated_base_url
|
|
543
|
+
url = base_url.to_s
|
|
544
|
+
if url.empty?
|
|
545
|
+
raise ArgumentError, "no base_url configured for #{self.class.name || self.class.inspect}; set one with " \
|
|
546
|
+
"base_url 'https://api.example.com' or define a base_url method"
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
self.class.validate_base_url!(url)
|
|
550
|
+
url
|
|
551
|
+
end
|
|
552
|
+
|
|
514
553
|
def expected_response_code!(response, expected_response_codes, _options)
|
|
515
554
|
return if expected_response_codes.empty? && response.is_a?(Net::HTTPSuccess)
|
|
516
555
|
return if expected_response_codes.include?(response.code)
|
|
@@ -552,8 +591,11 @@ module ClientApiBuilder
|
|
|
552
591
|
self
|
|
553
592
|
end
|
|
554
593
|
|
|
594
|
+
# Percent-encodes everything but RFC 3986 unreserved characters (A-Z a-z 0-9 - . _ ~),
|
|
595
|
+
# so a value inserted into the path, including any '/', stays one segment.
|
|
596
|
+
# Override to change how path values are encoded.
|
|
555
597
|
def escape_path(path)
|
|
556
|
-
path
|
|
598
|
+
ERB::Util.url_encode(path.to_s)
|
|
557
599
|
end
|
|
558
600
|
|
|
559
601
|
def instrument_request
|
|
@@ -590,6 +632,9 @@ module ClientApiBuilder
|
|
|
590
632
|
|
|
591
633
|
def request_wrapper(options, &block)
|
|
592
634
|
retry_request(options) do
|
|
635
|
+
# Clear the previous attempt's state so a failed attempt never reports an earlier response
|
|
636
|
+
@request_options = nil
|
|
637
|
+
@response = nil
|
|
593
638
|
instrument_request(&block)
|
|
594
639
|
end
|
|
595
640
|
end
|
|
@@ -8,7 +8,13 @@ module ClientApiBuilder
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
module ClassMethods
|
|
11
|
+
SECTION_NAME = /\A[a-z_][a-z0-9_]*\z/i
|
|
12
|
+
|
|
13
|
+
# Defines <name>_router (the section's NestedRouter class) and <name> (its router for a
|
|
14
|
+
# client instance) with closures, so anonymous client classes and any option values work.
|
|
11
15
|
def section(name, nested_router_options = {}, &)
|
|
16
|
+
raise ArgumentError, "Invalid section name: #{name.inspect}" unless name.to_s.match?(SECTION_NAME)
|
|
17
|
+
|
|
12
18
|
kls = InheritanceHelper::ClassBuilder::Utils.create_class(
|
|
13
19
|
self,
|
|
14
20
|
name,
|
|
@@ -18,16 +24,22 @@ module ClientApiBuilder
|
|
|
18
24
|
&
|
|
19
25
|
)
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
define_singleton_method(:"#{name}_router") { kls }
|
|
28
|
+
define_section_accessor(name, nested_router_options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Memoized per client instance; each instance gets its own copy of the options
|
|
34
|
+
def define_section_accessor(name, nested_router_options)
|
|
35
|
+
router_method = :"#{name}_router"
|
|
36
|
+
ivar = :"@#{name}"
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
define_method(name) do
|
|
39
|
+
instance_variable_get(ivar) ||
|
|
40
|
+
instance_variable_set(ivar, self.class.public_send(router_method)
|
|
41
|
+
.new(root_router, nested_router_options.dup))
|
|
42
|
+
end
|
|
31
43
|
end
|
|
32
44
|
end
|
|
33
45
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: client-api-builder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- lib/client_api_builder/nested_router.rb
|
|
44
44
|
- lib/client_api_builder/net_http_request.rb
|
|
45
45
|
- lib/client_api_builder/query_params.rb
|
|
46
|
+
- lib/client_api_builder/route_value_validator.rb
|
|
46
47
|
- lib/client_api_builder/router.rb
|
|
47
48
|
- lib/client_api_builder/section.rb
|
|
48
49
|
- lib/client_api_builder/version.rb
|