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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ee8b61c4583f46d39c0d502192fb713f502ece28b7ea98092b73572efc9ffd4
4
- data.tar.gz: bc80ea2afb78cdacc5067ebb45005a4bbcc997d13e79e9c6a9cf6b7edc8d5696
3
+ metadata.gz: aac435317feb174db101e201b00464222bfc2df0f10601ac88e7897c3694be27
4
+ data.tar.gz: '08ad14efc0bff6c1f6e7fd4864e26c6f41853d72808fdc84b0e4781a0cfd229f'
5
5
  SHA512:
6
- metadata.gz: 4fd43d2172214d83d6b235096bd17f6289a07243dae96f3911b66c783d11caf1695ee541c2a0d2b547acf305616819c749bb32168ca62b5fa98880b042050925
7
- data.tar.gz: e2d29d72f3c076be3b24d0941f317745ce367342cc96157f7c8d2a8592bc2dea0a733da1f4d9d5d40f46e1bf2859a01023ebb57c9ca31be4efca07e18267751e
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
- ### Build System
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
- * **release:** release 0.7.0 ([fff1354](https://github.com/dougyouch/client-api-builder/commit/fff13543d5d8189e2bb7a84eda7e8ca3181f3469))
55
+ ### Build
9
56
 
10
- ## Changelog
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
- [![Gem Version](https://badge.fury.io/rb/client-api-builder.svg)](https://badge.fury.io/rb/client-api-builder)
3
+ [![Gem Version](https://img.shields.io/gem/v/client-api-builder)](https://rubygems.org/gems/client-api-builder)
4
4
  [![CI](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
5
5
  [![Coverage](https://raw.githubusercontent.com/dougyouch/client-api-builder/badges/coverage.svg)](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
6
+ [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/client-api-builder/badges/branches.svg)](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 (`:get`, `:post`, `:put`, `:patch`, `:delete`). Auto-detected if omitted. |
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:` | Hash defining request body. Use symbols for dynamic values. |
99
- | `expected_response_code:` | Single expected HTTP status code |
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 automatically detects HTTP methods based on route names:
134
+ The Router detects the HTTP method from how the route name starts:
107
135
 
108
- | Prefix | HTTP Method |
109
- |--------|-------------|
110
- | `get_`, `find_`, `fetch_`, `list_`, `search_` | GET |
111
- | `post_`, `create_`, `add_`, `insert_` | POST |
112
- | `put_`, `update_`, `modify_`, `change_` | PUT |
113
- | `patch_` | PATCH |
114
- | `delete_`, `remove_`, `destroy_` | DELETE |
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` or `{param}` syntax):
165
+ **1. Path Parameters** (using `:param` syntax):
136
166
 
137
167
  ```ruby
138
168
  route :get_user, '/users/:id'
139
- route :get_post, '/users/{user_id}/posts/{post_id}'
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', :auth_token
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
- # Retry up to 3 times with 0.5 second delay between attempts
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
- By default, retries are performed only for network-related errors:
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
- The gem provides detailed error information:
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 available, the gem provides instrumentation and logging:
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[:method]} #{client.request_options[:uri]}"
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
- For production environments, it's important to log requests without exposing sensitive credentials that may be present in query parameters. The following example strips query parameters from logged URLs:
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
- All HTTPS connections verify SSL certificates by default using `OpenSSL::SSL::VERIFY_PEER`. Default timeouts are also configured to prevent hanging connections.
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 are validated to only allow `http` and `https` schemes, preventing Server-Side Request Forgery attacks:
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 operations validate paths to prevent directory traversal attacks:
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 will raise ArgumentError
487
- client.download_file(id: 1, file: '/tmp/../etc/passwd')
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(max, sleep)` | Configure retry behavior |
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`)
@@ -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
- method = client.request_options[:method].to_s.upcase
23
- uri = client.request_options[:uri]
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
- "#{method} #{uri.scheme}://#{uri.host}#{uri.path}[#{response_code}] took #{event.duration.to_i}ms"
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
- def instrument_request
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
- error = nil
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
@@ -17,7 +17,7 @@ module ClientApiBuilder
17
17
  end
18
18
 
19
19
  def self.get_instance_method(var)
20
- "\#{root_router.#{var}}"
20
+ "\#{escape_path(root_router.#{var})}"
21
21
  end
22
22
 
23
23
  def base_url
@@ -50,46 +50,78 @@ module ClientApiBuilder
50
50
  end
51
51
  end
52
52
 
53
- def stream(method:, uri:, body:, headers:, connection_options:)
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
- def stream_to_file(method:, uri:, body:, headers:, connection_options:, file:)
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
- File.open(expanded_path, mode) do |io|
91
- stream_to_io(method: method, uri: uri, body: body, headers: headers, connection_options: opts, io: io)
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
- # Deep duplicates a hash to prevent shared mutable state
28
- def deep_dup_hash(hash)
29
- hash.transform_values do |value|
30
- case value
31
- when Hash then deep_dup_hash(value)
32
- when Array then value.map { |v| v.is_a?(Hash) ? deep_dup_hash(v) : v }
33
- else value
34
- end
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
- return if ALLOWED_URL_SCHEMES.include?(uri.scheme&.downcase)
77
-
78
- allowed = ALLOWED_URL_SCHEMES.join(', ')
79
- raise ArgumentError, "Invalid base_url scheme: #{uri.scheme.inspect}. Allowed: #{allowed}"
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 /^(?:post|create|add|insert)/i
192
+ when /\A(?:post|create|add|insert)(?:_|\z)/i
175
193
  :post
176
- when /^(?:put|update|modify|change)/i
194
+ when /\A(?:put|update|modify|change)(?:_|\z)/i
177
195
  :put
178
- when /^(?:patch)/i
196
+ when /\Apatch(?:_|\z)/i
179
197
  :patch
180
- when /^(?:delete|remove)/i
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 do |k, v|
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 do |v, idx|
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(/:([a-z0-9_]+)/i) do |_match|
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
- query_arguments = get_arguments(options[:query])
316
- str = value_to_code(options[:query])
317
- str = str.gsub(/"__\|\|(.+?)\|\|__"/) { Regexp.last_match(1) }
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
- body_arguments = get_arguments(options[:body])
327
- str = value_to_code(options[:body])
328
- str = str.gsub(/"__\|\|(.+?)\|\|__"/) { Regexp.last_match(1) }
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) if 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
- add_header_proc = proc do |name, value|
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
- add_query_param_proc = proc do |name, value|
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 = base_url.to_s
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
- code = <<~CODE
22
- def self.#{name}_router
23
- #{kls.name}
24
- end
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
- def #{name}
27
- @#{name} ||= self.class.#{name}_router.new(self.root_router, #{nested_router_options.inspect})
28
- end
29
- CODE
30
- class_eval code, __FILE__, __LINE__
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ClientApiBuilder
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.7.0'
5
+ VERSION = '0.7.2'
6
6
  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.0
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