client-api-builder 0.7.0 → 0.7.1

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: 00351abdc1f660fefd19687f75b696db8a1b9b16390ff6427706a8f5db64ee74
4
+ data.tar.gz: 34dac63d4090971accab88566f4c217ccdb5a1d21ba000281ad5e2ea68bd74cb
5
5
  SHA512:
6
- metadata.gz: 4fd43d2172214d83d6b235096bd17f6289a07243dae96f3911b66c783d11caf1695ee541c2a0d2b547acf305616819c749bb32168ca62b5fa98880b042050925
7
- data.tar.gz: e2d29d72f3c076be3b24d0941f317745ce367342cc96157f7c8d2a8592bc2dea0a733da1f4d9d5d40f46e1bf2859a01023ebb57c9ca31be4efca07e18267751e
6
+ metadata.gz: b34e2623702b08c9ac8156e8f0fe9da060636823492ec49bc34832e4e5bff09bb8ab4b3244cbba38bc49ec1a01a80ae30a484c8ec85fb8ae7a032131156278f5
7
+ data.tar.gz: 4e164e2afd778cd2a6c2adb171c0ee6afec71eef925de9b61fe9b8c1b1d94a5faf2bd8fa624f6f691c4b7268d550e2e58dc82ef0c9b40470fe687026a5cef90f
data/CHANGELOG.md CHANGED
@@ -1,10 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1](https://github.com/dougyouch/client-api-builder/compare/v0.7.0...v0.7.1) (2026-10-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **router:** detect destroy_ routes as DELETE ([85604c4](https://github.com/dougyouch/client-api-builder/commit/85604c4d974c8483e292e700eb51a2c78e312b54))
9
+ * **router:** url-encode path values ([76d6fb4](https://github.com/dougyouch/client-api-builder/commit/76d6fb4fa246b342e263322d06262cba6760c278))
10
+
11
+ ### Upgrade Notes
12
+
13
+ * 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('/')`.
14
+
3
15
  ## [0.7.0](https://github.com/dougyouch/client-api-builder/compare/v0.6.1...v0.7.0) (2026-10-03)
4
16
 
5
17
 
6
- ### Build System
18
+ ### ⚠ BREAKING CHANGES
19
+
20
+ * 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))
21
+
22
+ ### Packaging
23
+
24
+ * 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))
25
+ * refresh the gem summary and description ([ca141a1](https://github.com/dougyouch/client-api-builder/commit/ca141a1))
26
+ * add `ClientApiBuilder::VERSION` ([7c951e0](https://github.com/dougyouch/client-api-builder/commit/7c951e0))
7
27
 
8
- * **release:** release 0.7.0 ([fff1354](https://github.com/dougyouch/client-api-builder/commit/fff13543d5d8189e2bb7a84eda7e8ca3181f3469))
28
+ ### Build
9
29
 
10
- ## Changelog
30
+ * automate versioning, changelog and RubyGems publishing with release-please ([7c951e0](https://github.com/dougyouch/client-api-builder/commit/7c951e0))
31
+ * replace Codecov with a GitHub-hosted coverage badge ([7c951e0](https://github.com/dougyouch/client-api-builder/commit/7c951e0))
32
+ * 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
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 | 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 match is on the start of the name only, so `address_lookup` is a POST. Pass `method:` when the name doesn't say it.
115
145
 
116
146
  ```ruby
117
147
  class MyApiClient
@@ -132,11 +162,31 @@ 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
+ `{name}` is filled from the client's own `name` method rather than an argument, which suits values like account IDs that are set once:
173
+
174
+ ```ruby
175
+ attr_accessor :account_id
176
+
177
+ route :get_invoices, '/accounts/{account_id}/invoices'
178
+ # client.get_invoices
179
+ ```
180
+
181
+ The same `'{name}'` form works as a value inside `query:` and `body:`.
182
+
183
+ 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
+
185
+ ```ruby
186
+ # Allow '/' in values, e.g. for nested object keys
187
+ def escape_path(value)
188
+ value.to_s.split('/').map { |part| ERB::Util.url_encode(part) }.join('/')
189
+ end
140
190
  ```
141
191
 
142
192
  **2. Query Parameters:**
@@ -193,7 +243,7 @@ class MyApiClient
193
243
  # Default: JSON (using to_json)
194
244
  body_builder :to_json
195
245
 
196
- # URL-encoded form data (using to_query)
246
+ # URL-encoded form data (using to_query, requires ActiveSupport)
197
247
  body_builder :to_query
198
248
 
199
249
  # Custom query params builder (no ActiveSupport dependency)
@@ -213,6 +263,8 @@ class MyApiClient
213
263
  end
214
264
  ```
215
265
 
266
+ 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.
267
+
216
268
  ### Nested Routing (Sections)
217
269
 
218
270
  Organize complex APIs with nested routes:
@@ -222,12 +274,17 @@ class MyApiClient
222
274
  include ClientApiBuilder::Router
223
275
 
224
276
  base_url 'https://api.example.com'
225
- header 'Authorization', :auth_token
277
+ header 'Authorization', :authorization
226
278
 
227
279
  attr_accessor :auth_token
228
280
 
281
+ def authorization
282
+ "Bearer #{auth_token}"
283
+ end
284
+
229
285
  section :users do
230
286
  base_url 'https://api.example.com/v2' # Override base URL
287
+ header 'Authorization', :authorization
231
288
 
232
289
  route :list, '/users'
233
290
  route :get, '/users/:id'
@@ -235,6 +292,8 @@ class MyApiClient
235
292
  end
236
293
 
237
294
  section :posts do
295
+ header 'Authorization', :authorization
296
+
238
297
  route :list, '/posts'
239
298
  route :get, '/posts/:id'
240
299
  end
@@ -249,6 +308,8 @@ user = client.users.get(id: 123)
249
308
  posts = client.posts.list
250
309
  ```
251
310
 
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, `{name}` path values, and response blocks are evaluated on the root client, so they can use its methods and state.
312
+
252
313
  ### Connection Options
253
314
 
254
315
  Configure connection settings:
@@ -268,6 +329,8 @@ class MyApiClient
268
329
  end
269
330
  ```
270
331
 
332
+ 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`.
333
+
271
334
  ### Retry Configuration
272
335
 
273
336
  Configure automatic retries for transient failures:
@@ -278,12 +341,14 @@ class MyApiClient
278
341
 
279
342
  base_url 'https://api.example.com'
280
343
 
281
- # Retry up to 3 times with 0.5 second delay between attempts
344
+ # Make up to 3 attempts in total, waiting 0.5 seconds between them
282
345
  configure_retries 3, 0.5
283
346
  end
284
347
  ```
285
348
 
286
- By default, retries are performed only for network-related errors:
349
+ 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.
350
+
351
+ Only these network errors are retried by default:
287
352
  - `Net::OpenTimeout`, `Net::ReadTimeout`
288
353
  - `Errno::ECONNRESET`, `Errno::ECONNREFUSED`, `Errno::ETIMEDOUT`
289
354
  - `SocketError`, `EOFError`
@@ -345,6 +410,8 @@ client.process_stream do |response, chunk|
345
410
  end
346
411
  ```
347
412
 
413
+ Streaming routes return the `Net::HTTPResponse`. Files are written in `wb` mode by default; pass `connection_options: { file_mode: 'ab' }` to append.
414
+
348
415
  ### Response Handling
349
416
 
350
417
  Customize how responses are processed:
@@ -370,11 +437,16 @@ class MyApiClient
370
437
  data
371
438
  end
372
439
  end
440
+
441
+ # A block passed to the call replaces the route's block
442
+ client.get_user(id: 1) { |data| data['name'] }
373
443
  ```
374
444
 
445
+ Blocks run on the client, so they can call its methods and set its state. Empty response bodies return `nil`.
446
+
375
447
  ### Error Handling
376
448
 
377
- The gem provides detailed error information:
449
+ `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
450
 
379
451
  ```ruby
380
452
  begin
@@ -412,12 +484,9 @@ puts client.request_attempts # Number of attempts (including retries)
412
484
 
413
485
  ### ActiveSupport Integration
414
486
 
415
- When ActiveSupport is available, the gem provides instrumentation and logging:
487
+ When ActiveSupport is loaded before your client class includes `ClientApiBuilder::Router`, every request is instrumented as a `client_api_builder.request` event:
416
488
 
417
489
  ```ruby
418
- # Set up logging
419
- ClientApiBuilder.logger = Logger.new(STDOUT)
420
-
421
490
  # Subscribe to request events
422
491
  ActiveSupport::Notifications.subscribe('client_api_builder.request') do |*args|
423
492
  event = ActiveSupport::Notifications::Event.new(*args)
@@ -433,9 +502,15 @@ subscriber = ClientApiBuilder::ActiveSupportLogSubscriber.new(Rails.logger)
433
502
  subscriber.subscribe!
434
503
  ```
435
504
 
505
+ Separately, `ClientApiBuilder.logger` receives every exception raised during a request attempt, including ones that are retried:
506
+
507
+ ```ruby
508
+ ClientApiBuilder.logger = Logger.new($stdout)
509
+ ```
510
+
436
511
  #### Production Logging
437
512
 
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:
513
+ The built-in log subscriber already leaves out query strings, which may hold credentials. To customize the format, subscribe directly:
439
514
 
440
515
  ```ruby
441
516
  ActiveSupport::Notifications.subscribe('client_api_builder.request') do |_, start_time, end_time, _, payload|
@@ -461,7 +536,7 @@ Client API Builder includes several security features enabled by default:
461
536
 
462
537
  ### SSL/TLS Verification
463
538
 
464
- All HTTPS connections verify SSL certificates by default using `OpenSSL::SSL::VERIFY_PEER`. Default timeouts are also configured to prevent hanging connections.
539
+ 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
540
 
466
541
  ### SSRF Protection
467
542
 
@@ -478,9 +553,13 @@ class MyApiClient
478
553
  end
479
554
  ```
480
555
 
556
+ ### Path Value Encoding
557
+
558
+ 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
+
481
560
  ### Path Traversal Protection
482
561
 
483
- File streaming operations validate paths to prevent directory traversal attacks:
562
+ File streaming rejects any path containing `..` or a null byte:
484
563
 
485
564
  ```ruby
486
565
  # These will raise ArgumentError
@@ -522,12 +601,12 @@ end
522
601
  | Method | Description |
523
602
  |--------|-------------|
524
603
  | `base_url(url)` | Set the base URL for all requests |
525
- | `header(name, value)` | Add a header to all requests |
604
+ | `header(name, value = nil, &block)` | Add a header to all requests (value, method name symbol, or block) |
526
605
  | `body_builder(builder)` | Configure request body serialization |
527
606
  | `query_builder(builder)` | Configure query string serialization |
528
- | `query_param(name, value)` | Add a query parameter to all requests |
607
+ | `query_param(name, value = nil, &block)` | Add a query parameter to all requests (value, method name symbol, or block) |
529
608
  | `connection_option(name, value)` | Set Net::HTTP connection options |
530
- | `configure_retries(max, sleep)` | Configure retry behavior |
609
+ | `configure_retries(max_attempts, sleep = 0.05)` | Configure retry behavior |
531
610
  | `route(name, path, options)` | Define an API endpoint |
532
611
  | `section(name, options, &block)` | Define nested routes |
533
612
  | `namespace(path, &block)` | Add path prefix to routes in block |
@@ -541,11 +620,27 @@ end
541
620
  | `total_request_time` | Duration of last request in seconds |
542
621
  | `request_attempts` | Number of attempts for last request |
543
622
  | `root_router` | Returns the root router (for nested routers) |
623
+ | `base_url` | Base URL used for requests |
624
+
625
+ ### Overridable Hooks
626
+
627
+ Define these in your client to change default behavior:
628
+
629
+ | Method | Default |
630
+ |--------|---------|
631
+ | `retry_request?(exception, options)` | `true` for the network errors listed under Retry Configuration |
632
+ | `escape_path(value)` | Percent-encodes path values (`ERB::Util.url_encode`) |
633
+ | `parse_response(response, options)` | Parses the body as JSON, `nil` when empty |
634
+ | `handle_response(response, options, &block)` | Applies `return:`, parsing and the response block |
635
+ | `expected_response_code!(response, codes, options)` | Raises `UnexpectedResponse` for unexpected codes |
636
+ | `get_retry_request_max_retries(options)` | `retries:` option, then `configure_retries`, then 1 |
637
+ | `get_retry_request_sleep_time(exception, options)` | `sleep:` option, then `configure_retries`, then 0.05 |
544
638
 
545
639
  ## Requirements
546
640
 
547
641
  - Ruby 3.2+
548
642
  - `inheritance-helper` gem (>= 0.2.5)
643
+ - `activesupport` (optional) for `to_query` builders and instrumentation
549
644
 
550
645
  ## Contributing
551
646
 
@@ -554,7 +649,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/dougyo
554
649
  1. Fork the repository
555
650
  2. Create your feature branch (`git checkout -b feature/my-feature`)
556
651
  3. Write tests for your changes
557
- 4. Ensure all tests pass (`bundle exec rspec`)
652
+ 4. Ensure all tests pass with full line and branch coverage (`CI=true bundle exec rspec`)
558
653
  5. Ensure code style compliance (`bundle exec rubocop`)
559
654
  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
655
  7. Push to the branch (`git push origin feature/my-feature`)
@@ -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
@@ -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
 
@@ -177,7 +178,7 @@ module ClientApiBuilder
177
178
  :put
178
179
  when /^(?:patch)/i
179
180
  :patch
180
- when /^(?:delete|remove)/i
181
+ when /^(?:delete|remove|destroy)/i
181
182
  :delete
182
183
  else
183
184
  :get
@@ -552,8 +553,11 @@ module ClientApiBuilder
552
553
  self
553
554
  end
554
555
 
556
+ # Percent-encodes everything but RFC 3986 unreserved characters (A-Z a-z 0-9 - . _ ~),
557
+ # so a value inserted into the path, including any '/', stays one segment.
558
+ # Override to change how path values are encoded.
555
559
  def escape_path(path)
556
- path
560
+ ERB::Util.url_encode(path.to_s)
557
561
  end
558
562
 
559
563
  def instrument_request
@@ -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.1'
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch