client-api-builder 0.7.1 → 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 +27 -0
- data/README.md +46 -11
- 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/net_http_request.rb +55 -23
- data/lib/client_api_builder/route_value_validator.rb +54 -0
- data/lib/client_api_builder/router.rb +141 -100
- 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,5 +1,32 @@
|
|
|
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
|
+
|
|
3
30
|
## [0.7.1](https://github.com/dougyouch/client-api-builder/compare/v0.7.0...v0.7.1) (2026-10-03)
|
|
4
31
|
|
|
5
32
|
|
data/README.md
CHANGED
|
@@ -119,7 +119,7 @@ Every generated method also accepts options that apply to that call only:
|
|
|
119
119
|
```ruby
|
|
120
120
|
client.get_user(
|
|
121
121
|
id: 1,
|
|
122
|
-
headers: { 'X-Trace-Id' => 'abc' }, # merged over the class headers
|
|
122
|
+
headers: { 'X-Trace-Id' => 'abc' }, # merged over the class headers; nil removes one
|
|
123
123
|
query: { expand: 'teams' }, # merged over the route's query params
|
|
124
124
|
body: { name: 'Ann' }, # replaces the route's body
|
|
125
125
|
connection_options: { read_timeout: 5 },
|
|
@@ -133,7 +133,7 @@ client.get_user(
|
|
|
133
133
|
|
|
134
134
|
The Router detects the HTTP method from how the route name starts:
|
|
135
135
|
|
|
136
|
-
| Name starts with | HTTP Method |
|
|
136
|
+
| Name starts with the word | HTTP Method |
|
|
137
137
|
|------------------|-------------|
|
|
138
138
|
| `post`, `create`, `add`, `insert` | POST |
|
|
139
139
|
| `put`, `update`, `modify`, `change` | PUT |
|
|
@@ -141,7 +141,7 @@ The Router detects the HTTP method from how the route name starts:
|
|
|
141
141
|
| `delete`, `remove`, `destroy` | DELETE |
|
|
142
142
|
| anything else | GET |
|
|
143
143
|
|
|
144
|
-
The
|
|
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.
|
|
145
145
|
|
|
146
146
|
```ruby
|
|
147
147
|
class MyApiClient
|
|
@@ -169,7 +169,9 @@ route :get_user, '/users/:id'
|
|
|
169
169
|
# client.get_user(id: 1)
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
-
`
|
|
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):
|
|
173
175
|
|
|
174
176
|
```ruby
|
|
175
177
|
attr_accessor :account_id
|
|
@@ -178,7 +180,17 @@ route :get_invoices, '/accounts/{account_id}/invoices'
|
|
|
178
180
|
# client.get_invoices
|
|
179
181
|
```
|
|
180
182
|
|
|
181
|
-
The same `
|
|
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.
|
|
182
194
|
|
|
183
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`:
|
|
184
196
|
|
|
@@ -232,6 +244,8 @@ class MyApiClient
|
|
|
232
244
|
end
|
|
233
245
|
```
|
|
234
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
|
+
|
|
235
249
|
### Request Body Formats
|
|
236
250
|
|
|
237
251
|
Configure how request bodies are serialized:
|
|
@@ -308,7 +322,7 @@ user = client.users.get(id: 123)
|
|
|
308
322
|
posts = client.posts.list
|
|
309
323
|
```
|
|
310
324
|
|
|
311
|
-
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
|
|
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.
|
|
312
326
|
|
|
313
327
|
### Connection Options
|
|
314
328
|
|
|
@@ -412,6 +426,8 @@ end
|
|
|
412
426
|
|
|
413
427
|
Streaming routes return the `Net::HTTPResponse`. Files are written in `wb` mode by default; pass `connection_options: { file_mode: 'ab' }` to append.
|
|
414
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
|
+
|
|
415
431
|
### Response Handling
|
|
416
432
|
|
|
417
433
|
Customize how responses are processed:
|
|
@@ -444,6 +460,8 @@ client.get_user(id: 1) { |data| data['name'] }
|
|
|
444
460
|
|
|
445
461
|
Blocks run on the client, so they can call its methods and set its state. Empty response bodies return `nil`.
|
|
446
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
|
+
|
|
447
465
|
### Error Handling
|
|
448
466
|
|
|
449
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:
|
|
@@ -482,6 +500,8 @@ puts client.total_request_time # Time in seconds
|
|
|
482
500
|
puts client.request_attempts # Number of attempts (including retries)
|
|
483
501
|
```
|
|
484
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
|
+
|
|
485
505
|
### ActiveSupport Integration
|
|
486
506
|
|
|
487
507
|
When ActiveSupport is loaded before your client class includes `ClientApiBuilder::Router`, every request is instrumented as a `client_api_builder.request` event:
|
|
@@ -492,9 +512,10 @@ ActiveSupport::Notifications.subscribe('client_api_builder.request') do |*args|
|
|
|
492
512
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
493
513
|
client = event.payload[:client]
|
|
494
514
|
|
|
495
|
-
puts "#{client.request_options
|
|
515
|
+
puts "#{client.request_options&.dig(:method)} #{client.request_options&.dig(:uri)}"
|
|
496
516
|
puts "Status: #{client.response&.code}"
|
|
497
517
|
puts "Duration: #{event.duration.round(2)}ms"
|
|
518
|
+
puts "Failed: #{event.payload[:exception_object].inspect}" if event.payload[:exception]
|
|
498
519
|
end
|
|
499
520
|
|
|
500
521
|
# Or use the built-in log subscriber
|
|
@@ -502,6 +523,13 @@ subscriber = ClientApiBuilder::ActiveSupportLogSubscriber.new(Rails.logger)
|
|
|
502
523
|
subscriber.subscribe!
|
|
503
524
|
```
|
|
504
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
|
+
|
|
505
533
|
Separately, `ClientApiBuilder.logger` receives every exception raised during a request attempt, including ones that are retried:
|
|
506
534
|
|
|
507
535
|
```ruby
|
|
@@ -540,7 +568,7 @@ HTTPS connections verify SSL certificates using `OpenSSL::SSL::VERIFY_PEER` and
|
|
|
540
568
|
|
|
541
569
|
### SSRF Protection
|
|
542
570
|
|
|
543
|
-
Base URLs
|
|
571
|
+
Base URLs must use the `http` or `https` scheme and include a host, preventing Server-Side Request Forgery attacks:
|
|
544
572
|
|
|
545
573
|
```ruby
|
|
546
574
|
class MyApiClient
|
|
@@ -553,20 +581,27 @@ class MyApiClient
|
|
|
553
581
|
end
|
|
554
582
|
```
|
|
555
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
|
+
|
|
556
586
|
### Path Value Encoding
|
|
557
587
|
|
|
558
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.
|
|
559
589
|
|
|
560
590
|
### Path Traversal Protection
|
|
561
591
|
|
|
562
|
-
File streaming rejects
|
|
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:
|
|
563
593
|
|
|
564
594
|
```ruby
|
|
565
|
-
# These
|
|
566
|
-
client.download_file(id: 1, file: '
|
|
595
|
+
# These raise ArgumentError
|
|
596
|
+
client.download_file(id: 1, file: "/downloads/#{'../etc/passwd'}")
|
|
567
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')
|
|
568
601
|
```
|
|
569
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
|
+
|
|
570
605
|
### Safe File Modes
|
|
571
606
|
|
|
572
607
|
Only safe file modes are allowed for streaming to files: `w`, `wb`, `a`, `ab`, `w+`, `wb+`, `a+`, `ab+`.
|
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
|
|
@@ -25,17 +25,31 @@ module ClientApiBuilder
|
|
|
25
25
|
# Allowed URL schemes for base_url to prevent SSRF attacks
|
|
26
26
|
ALLOWED_URL_SCHEMES = %w[http https].freeze
|
|
27
27
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
36
46
|
end
|
|
37
47
|
end
|
|
38
48
|
|
|
49
|
+
def deep_dup_hash(hash)
|
|
50
|
+
deep_dup(hash)
|
|
51
|
+
end
|
|
52
|
+
|
|
39
53
|
def default_options
|
|
40
54
|
{
|
|
41
55
|
base_url: nil,
|
|
@@ -50,7 +64,7 @@ module ClientApiBuilder
|
|
|
50
64
|
}.freeze
|
|
51
65
|
end
|
|
52
66
|
|
|
53
|
-
# tracks the proc used to handle responses
|
|
67
|
+
# tracks the proc used to handle responses; nil clears it
|
|
54
68
|
def add_response_proc(method_name, proc)
|
|
55
69
|
response_procs = deep_dup_hash(default_options[:response_procs])
|
|
56
70
|
response_procs[method_name] = proc
|
|
@@ -74,10 +88,11 @@ module ClientApiBuilder
|
|
|
74
88
|
# Validates that base_url uses an allowed scheme
|
|
75
89
|
def validate_base_url!(url)
|
|
76
90
|
uri = URI.parse(url.to_s)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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?
|
|
81
96
|
rescue URI::InvalidURIError => e
|
|
82
97
|
raise ArgumentError, "Invalid base_url: #{e.message}"
|
|
83
98
|
end
|
|
@@ -170,15 +185,17 @@ module ClientApiBuilder
|
|
|
170
185
|
end
|
|
171
186
|
end
|
|
172
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.
|
|
173
190
|
def auto_detect_http_method(method_name)
|
|
174
191
|
case method_name.to_s
|
|
175
|
-
when
|
|
192
|
+
when /\A(?:post|create|add|insert)(?:_|\z)/i
|
|
176
193
|
:post
|
|
177
|
-
when
|
|
194
|
+
when /\A(?:put|update|modify|change)(?:_|\z)/i
|
|
178
195
|
:put
|
|
179
|
-
when
|
|
196
|
+
when /\Apatch(?:_|\z)/i
|
|
180
197
|
:patch
|
|
181
|
-
when
|
|
198
|
+
when /\A(?:delete|remove|destroy)(?:_|\z)/i
|
|
182
199
|
:delete
|
|
183
200
|
else
|
|
184
201
|
:get
|
|
@@ -192,48 +209,56 @@ module ClientApiBuilder
|
|
|
192
209
|
REQUIRED_BODY_HTTP_METHODS.include?(http_method)
|
|
193
210
|
end
|
|
194
211
|
|
|
212
|
+
# Replaces argument symbols and '{name}' strings in a query/body hash, in place, with
|
|
213
|
+
# CodeSnippets; returns the argument names found
|
|
195
214
|
def get_hash_arguments(hsh)
|
|
196
215
|
arguments = []
|
|
197
|
-
hsh.each
|
|
198
|
-
case v
|
|
199
|
-
when Symbol
|
|
200
|
-
hsh[k] = "__||#{v}||__"
|
|
201
|
-
arguments << v
|
|
202
|
-
when Hash
|
|
203
|
-
arguments += get_hash_arguments(v)
|
|
204
|
-
when Array
|
|
205
|
-
arguments += get_array_arguments(v)
|
|
206
|
-
when String
|
|
207
|
-
# Use match with block form to avoid thread-unsafe $1 global variable
|
|
208
|
-
if (match = v.match(/\{([a-z0-9_]+)\}/i))
|
|
209
|
-
hsh[k] = "__||#{match[1]}||__"
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
end
|
|
216
|
+
hsh.each { |key, value| hsh[key] = argument_code(value, arguments) }
|
|
213
217
|
arguments
|
|
214
218
|
end
|
|
215
219
|
|
|
220
|
+
# Same as get_hash_arguments, for an array
|
|
216
221
|
def get_array_arguments(list)
|
|
217
222
|
arguments = []
|
|
218
|
-
list.each_with_index
|
|
219
|
-
case v
|
|
220
|
-
when Symbol
|
|
221
|
-
list[idx] = "__||#{v}||__"
|
|
222
|
-
arguments << v
|
|
223
|
-
when Hash
|
|
224
|
-
arguments += get_hash_arguments(v)
|
|
225
|
-
when Array
|
|
226
|
-
arguments += get_array_arguments(v)
|
|
227
|
-
when String
|
|
228
|
-
# Use match with block form to avoid thread-unsafe $1 global variable
|
|
229
|
-
if (match = v.match(/\{([a-z0-9_]+)\}/i))
|
|
230
|
-
list[idx] = "__||#{match[1]}||__"
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
end
|
|
223
|
+
list.each_with_index { |value, idx| list[idx] = argument_code(value, arguments) }
|
|
234
224
|
arguments
|
|
235
225
|
end
|
|
236
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
|
+
|
|
237
262
|
# returns a list of arguments to add to the route method
|
|
238
263
|
def get_arguments(value)
|
|
239
264
|
case value
|
|
@@ -275,7 +300,7 @@ module ClientApiBuilder
|
|
|
275
300
|
end
|
|
276
301
|
|
|
277
302
|
path_arguments = []
|
|
278
|
-
path = path.gsub(
|
|
303
|
+
path = path.gsub(PATH_PARAMETER) do |_match|
|
|
279
304
|
param_name = Regexp.last_match(1)
|
|
280
305
|
path_arguments << param_name
|
|
281
306
|
"#\{escape_path(#{param_name})}"
|
|
@@ -293,7 +318,7 @@ module ClientApiBuilder
|
|
|
293
318
|
|
|
294
319
|
pairs = value.map do |k, v|
|
|
295
320
|
key_code = case k
|
|
296
|
-
when Symbol then "#{k}: "
|
|
321
|
+
when Symbol then k.match?(RouteValueValidator::ARGUMENT_NAME) ? "#{k}: " : "#{k.inspect} => "
|
|
297
322
|
when String then "#{k.inspect} => "
|
|
298
323
|
else "#{value_to_code(k)} => "
|
|
299
324
|
end
|
|
@@ -306,17 +331,19 @@ module ClientApiBuilder
|
|
|
306
331
|
'nil'
|
|
307
332
|
when TrueClass, FalseClass
|
|
308
333
|
value.to_s
|
|
334
|
+
when CodeSnippet
|
|
335
|
+
value.code
|
|
309
336
|
else
|
|
310
337
|
value.inspect
|
|
311
338
|
end
|
|
312
339
|
end
|
|
313
340
|
|
|
341
|
+
# get_arguments rewrites values in place, so work on a copy of the caller's query/body
|
|
314
342
|
def build_query_code(options)
|
|
315
343
|
if options[:query]
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
[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)]
|
|
320
347
|
else
|
|
321
348
|
['nil', []]
|
|
322
349
|
end
|
|
@@ -324,10 +351,9 @@ module ClientApiBuilder
|
|
|
324
351
|
|
|
325
352
|
def build_body_code(options, has_body_param)
|
|
326
353
|
if options[:body]
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
[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]
|
|
331
357
|
else
|
|
332
358
|
[has_body_param ? 'body' : 'nil', [], has_body_param]
|
|
333
359
|
end
|
|
@@ -352,18 +378,26 @@ module ClientApiBuilder
|
|
|
352
378
|
args + ['**__options__', '&block']
|
|
353
379
|
end
|
|
354
380
|
|
|
355
|
-
def generate_request_call_code(options, stream_param)
|
|
381
|
+
def generate_request_call_code(options, stream_param, expected_response_codes)
|
|
356
382
|
code = " @request_options[:#{stream_param}] = #{stream_param}\n" if stream_param
|
|
357
383
|
code ||= ''
|
|
384
|
+
validator = stream_validator_code(expected_response_codes)
|
|
358
385
|
|
|
359
386
|
code + case options[:stream]
|
|
360
|
-
when true, :file then " @response = stream_to_file(**@request_options)\n"
|
|
361
|
-
when :io then " @response = stream_to_io(**@request_options)\n"
|
|
362
|
-
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"
|
|
363
390
|
else " @response = request(**@request_options)\n"
|
|
364
391
|
end
|
|
365
392
|
end
|
|
366
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
|
+
|
|
367
401
|
def generate_response_handling_code(options)
|
|
368
402
|
if options[:stream] || options[:return] == :response
|
|
369
403
|
" @response\n"
|
|
@@ -380,6 +414,9 @@ module ClientApiBuilder
|
|
|
380
414
|
raise ArgumentError, "Invalid method name: #{method_name.inspect}"
|
|
381
415
|
end
|
|
382
416
|
|
|
417
|
+
RouteValueValidator.validate!(method_name, :query, options[:query])
|
|
418
|
+
RouteValueValidator.validate!(method_name, :body, options[:body])
|
|
419
|
+
|
|
383
420
|
http_method = options[:method] || auto_detect_http_method(method_name)
|
|
384
421
|
path, path_arguments = process_route_path(path)
|
|
385
422
|
has_body_param = options[:body].nil? && requires_body?(http_method, options)
|
|
@@ -413,7 +450,7 @@ module ClientApiBuilder
|
|
|
413
450
|
code += " __connection_options__ = build_connection_options(__options__)\n"
|
|
414
451
|
code += " @request_options = {method: #{ctx[:http_method].inspect}, uri: __uri__, body: __body__, " \
|
|
415
452
|
"headers: __headers__, connection_options: __connection_options__}\n"
|
|
416
|
-
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])
|
|
417
454
|
"#{code}end\n\n"
|
|
418
455
|
end
|
|
419
456
|
|
|
@@ -431,8 +468,10 @@ module ClientApiBuilder
|
|
|
431
468
|
"#{code}end\n"
|
|
432
469
|
end
|
|
433
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
|
|
434
473
|
def route(method_name, path, options = {}, &block)
|
|
435
|
-
add_response_proc(method_name, block)
|
|
474
|
+
add_response_proc(method_name, block)
|
|
436
475
|
|
|
437
476
|
class_eval generate_route_code(method_name, path, options), __FILE__, __LINE__
|
|
438
477
|
end
|
|
@@ -442,24 +481,12 @@ module ClientApiBuilder
|
|
|
442
481
|
self.class.base_url
|
|
443
482
|
end
|
|
444
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.
|
|
445
486
|
def build_headers(options)
|
|
446
|
-
headers = {}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
headers[name] =
|
|
450
|
-
if value.is_a?(Proc)
|
|
451
|
-
root_router.instance_eval(&value)
|
|
452
|
-
elsif value.is_a?(Symbol)
|
|
453
|
-
root_router.send(value)
|
|
454
|
-
else
|
|
455
|
-
value
|
|
456
|
-
end
|
|
457
|
-
end
|
|
458
|
-
|
|
459
|
-
self.class.default_headers.each(&add_header_proc)
|
|
460
|
-
options[:headers]&.each(&add_header_proc)
|
|
461
|
-
|
|
462
|
-
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 }
|
|
463
490
|
end
|
|
464
491
|
|
|
465
492
|
def build_connection_options(options)
|
|
@@ -470,27 +497,26 @@ module ClientApiBuilder
|
|
|
470
497
|
end
|
|
471
498
|
end
|
|
472
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.
|
|
473
502
|
def build_query(query, options)
|
|
474
|
-
query_params = {}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
query_params[name] =
|
|
478
|
-
if value.is_a?(Proc)
|
|
479
|
-
root_router.instance_eval(&value)
|
|
480
|
-
elsif value.is_a?(Symbol)
|
|
481
|
-
root_router.send(value)
|
|
482
|
-
else
|
|
483
|
-
value
|
|
484
|
-
end
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
self.class.default_query_params.each(&add_query_param_proc)
|
|
488
|
-
query&.each(&add_query_param_proc)
|
|
489
|
-
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]
|
|
490
506
|
|
|
491
507
|
query_params.empty? ? nil : self.class.build_query(self, query_params)
|
|
492
508
|
end
|
|
493
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
|
+
|
|
494
520
|
def build_body(body, options)
|
|
495
521
|
body = options[:body] if options.key?(:body)
|
|
496
522
|
|
|
@@ -502,8 +528,7 @@ module ClientApiBuilder
|
|
|
502
528
|
|
|
503
529
|
def build_uri(path, query, options)
|
|
504
530
|
# Properly join base_url and path to handle missing/extra slashes
|
|
505
|
-
base =
|
|
506
|
-
base = base.chomp('/') if base.end_with?('/')
|
|
531
|
+
base = validated_base_url.chomp('/')
|
|
507
532
|
path = path.to_s
|
|
508
533
|
path = "/#{path}" unless path.start_with?('/')
|
|
509
534
|
|
|
@@ -512,6 +537,19 @@ module ClientApiBuilder
|
|
|
512
537
|
uri
|
|
513
538
|
end
|
|
514
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
|
+
|
|
515
553
|
def expected_response_code!(response, expected_response_codes, _options)
|
|
516
554
|
return if expected_response_codes.empty? && response.is_a?(Net::HTTPSuccess)
|
|
517
555
|
return if expected_response_codes.include?(response.code)
|
|
@@ -594,6 +632,9 @@ module ClientApiBuilder
|
|
|
594
632
|
|
|
595
633
|
def request_wrapper(options, &block)
|
|
596
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
|
|
597
638
|
instrument_request(&block)
|
|
598
639
|
end
|
|
599
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
|