client-api-builder 0.6.1 → 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: a417fa853b68dc6e9e96eff4cf99d6a0e026d459e38ae57cbec7341e4735e291
4
- data.tar.gz: b2cb35125eb9cdea344051788aee903daea7762c1f0bbbfc13526e1a8815692b
3
+ metadata.gz: 00351abdc1f660fefd19687f75b696db8a1b9b16390ff6427706a8f5db64ee74
4
+ data.tar.gz: 34dac63d4090971accab88566f4c217ccdb5a1d21ba000281ad5e2ea68bd74cb
5
5
  SHA512:
6
- metadata.gz: 7fbc5052d79571ea1b901067bbaa3feb9a5e0e3c29ccebe1279905b917c121ecda94284f583c52badc5f00ef2c8c55fcba062563e1e8446730ea496659ac5ba3
7
- data.tar.gz: f2521e526d45195ff795ad3663c7f6bf8140bb8316a8c3b04f170fe215c0b185745536b152ea0879a896d60ea82cc33a7e7e2e0a9086892c1ee1c5c6a82125eb
6
+ metadata.gz: b34e2623702b08c9ac8156e8f0fe9da060636823492ec49bc34832e4e5bff09bb8ab4b3244cbba38bc49ec1a01a80ae30a484c8ec85fb8ae7a032131156278f5
7
+ data.tar.gz: 4e164e2afd778cd2a6c2adb171c0ee6afec71eef925de9b61fe9b8c1b1d94a5faf2bd8fa624f6f691c4b7268d550e2e58dc82ef0c9b40470fe687026a5cef90f
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
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
+
15
+ ## [0.7.0](https://github.com/dougyouch/client-api-builder/compare/v0.6.1...v0.7.0) (2026-10-03)
16
+
17
+
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))
27
+
28
+ ### Build
29
+
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)
4
- [![CI](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml/badge.svg)](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
5
- [![codecov](https://codecov.io/gh/dougyouch/client-api-builder/branch/master/graph/badge.svg)](https://codecov.io/gh/dougyouch/client-api-builder)
3
+ [![Gem Version](https://img.shields.io/gem/v/client-api-builder)](https://rubygems.org/gems/client-api-builder)
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
+ [![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
- - Ruby 3.0+
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,9 +649,9 @@ 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
- 6. Commit your changes (`git commit -am 'Add my feature'`)
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`)
561
656
  8. Create a Pull Request
562
657
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'client_api_builder/version'
4
+
3
5
  module ClientApiBuilder
4
6
  class Error < StandardError; end
5
7
 
@@ -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
@@ -51,7 +51,8 @@ module ClientApiBuilder
51
51
  end
52
52
 
53
53
  def stream(method:, uri:, body:, headers:, connection_options:)
54
- request(method: method, uri: uri, body: body, headers: headers, connection_options: connection_options) do |response|
54
+ request(method: method, uri: uri, body: body, headers: headers,
55
+ connection_options: connection_options) do |response|
55
56
  response.read_body do |chunk|
56
57
  yield response, chunk
57
58
  end
@@ -59,7 +60,8 @@ module ClientApiBuilder
59
60
  end
60
61
 
61
62
  def stream_to_io(method:, uri:, body:, headers:, connection_options:, io:)
62
- stream(method: method, uri: uri, body: body, headers: headers, connection_options: connection_options) do |_, chunk|
63
+ stream(method: method, uri: uri, body: body, headers: headers,
64
+ connection_options: connection_options) do |_, chunk|
63
65
  io.write chunk
64
66
  end
65
67
  end
@@ -75,12 +77,15 @@ module ClientApiBuilder
75
77
  elsif ALLOWED_FILE_MODES.include?(mode.to_s)
76
78
  mode.to_s
77
79
  else
78
- raise ArgumentError, "Invalid file mode: #{mode.inspect}. Allowed modes: #{ALLOWED_FILE_MODES.join(', ')}"
80
+ raise ArgumentError,
81
+ "Invalid file mode: #{mode.inspect}. Allowed modes: #{ALLOWED_FILE_MODES.join(', ')}"
79
82
  end
80
83
 
81
84
  # Validate file path - expand to absolute path and check for path traversal
82
85
  expanded_path = File.expand_path(file)
83
- raise ArgumentError, 'Invalid file path: potential path traversal detected' if file.to_s.include?('..') || expanded_path.include?("\0")
86
+ if file.to_s.include?('..') || expanded_path.include?("\0")
87
+ raise ArgumentError, 'Invalid file path: potential path traversal detected'
88
+ end
84
89
 
85
90
  File.open(expanded_path, mode) do |io|
86
91
  stream_to_io(method: method, uri: uri, body: body, headers: headers, connection_options: opts, io: io)
@@ -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
 
@@ -75,13 +76,14 @@ module ClientApiBuilder
75
76
  uri = URI.parse(url.to_s)
76
77
  return if ALLOWED_URL_SCHEMES.include?(uri.scheme&.downcase)
77
78
 
78
- raise ArgumentError, "Invalid base_url scheme: #{uri.scheme.inspect}. Allowed: #{ALLOWED_URL_SCHEMES.join(', ')}"
79
+ allowed = ALLOWED_URL_SCHEMES.join(', ')
80
+ raise ArgumentError, "Invalid base_url scheme: #{uri.scheme.inspect}. Allowed: #{allowed}"
79
81
  rescue URI::InvalidURIError => e
80
82
  raise ArgumentError, "Invalid base_url: #{e.message}"
81
83
  end
82
84
 
83
- # set the builder to :to_json, :to_query, :query_params or specify a proc to handle building the request body payload
84
- # or get the body builder
85
+ # set the builder to :to_json, :to_query, :query_params or specify a proc
86
+ # to handle building the request body payload, or get the body builder
85
87
  def body_builder(builder = nil, &block)
86
88
  return default_options[:body_builder] if builder.nil? && block.nil?
87
89
 
@@ -176,7 +178,7 @@ module ClientApiBuilder
176
178
  :put
177
179
  when /^(?:patch)/i
178
180
  :patch
179
- when /^(?:delete|remove)/i
181
+ when /^(?:delete|remove|destroy)/i
180
182
  :delete
181
183
  else
182
184
  :get
@@ -332,7 +334,7 @@ module ClientApiBuilder
332
334
  end
333
335
 
334
336
  def extract_expected_response_codes(options)
335
- codes = options[:expected_response_codes] || (options[:expected_response_code] ? [options[:expected_response_code]] : [])
337
+ codes = options[:expected_response_codes] || Array(options[:expected_response_code])
336
338
  codes.map(&:to_s)
337
339
  end
338
340
 
@@ -374,7 +376,9 @@ module ClientApiBuilder
374
376
 
375
377
  def generate_route_code(method_name, path, options = {})
376
378
  # Validate method_name to prevent code injection
377
- raise ArgumentError, "Invalid method name: #{method_name.inspect}" unless method_name.to_s.match?(/\A[a-z_][a-z0-9_]*\z/i)
379
+ unless method_name.to_s.match?(/\A[a-z_][a-z0-9_]*\z/i)
380
+ raise ArgumentError, "Invalid method name: #{method_name.inspect}"
381
+ end
378
382
 
379
383
  http_method = options[:method] || auto_detect_http_method(method_name)
380
384
  path, path_arguments = process_route_path(path)
@@ -410,7 +414,7 @@ module ClientApiBuilder
410
414
  code += " @request_options = {method: #{ctx[:http_method].inspect}, uri: __uri__, body: __body__, " \
411
415
  "headers: __headers__, connection_options: __connection_options__}\n"
412
416
  code += generate_request_call_code(ctx[:options], ctx[:stream_param])
413
- code + "end\n\n"
417
+ "#{code}end\n\n"
414
418
  end
415
419
 
416
420
  def generate_wrapper_method(ctx)
@@ -424,7 +428,7 @@ module ClientApiBuilder
424
428
  code += " expected_response_code!(@response, __expected_response_codes__, __options__)\n"
425
429
  code += generate_response_handling_code(ctx[:options])
426
430
  code += " end\n"
427
- code + "end\n"
431
+ "#{code}end\n"
428
432
  end
429
433
 
430
434
  def route(method_name, path, options = {}, &block)
@@ -549,8 +553,11 @@ module ClientApiBuilder
549
553
  self
550
554
  end
551
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.
552
559
  def escape_path(path)
553
- path
560
+ ERB::Util.url_encode(path.to_s)
554
561
  end
555
562
 
556
563
  def instrument_request
@@ -577,7 +584,7 @@ module ClientApiBuilder
577
584
  end
578
585
  end
579
586
 
580
- def get_retry_request_sleep_time(_e, options)
587
+ def get_retry_request_sleep_time(_exception, options)
581
588
  options[:sleep] || self.class.default_options[:sleep] || 0.05
582
589
  end
583
590
 
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClientApiBuilder
4
+ # Gem version, bumped by release-please
5
+ VERSION = '0.7.1'
6
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: client-api-builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: inheritance-helper
@@ -23,33 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: 0.2.5
26
- description: |
27
- A Ruby gem for building API clients through declarative configuration. Features include
28
- automatic HTTP method detection, nested routing, streaming support, configurable retries,
29
- and security features like SSL verification, SSRF protection, and path traversal prevention.
30
- Define your API endpoints with a clean DSL and get comprehensive error handling, debugging
31
- capabilities, and optional ActiveSupport integration for logging and instrumentation.
26
+ description: Client API Builder generates HTTP client methods from a declarative route
27
+ DSL. It infers HTTP methods from route names, builds query strings and request bodies,
28
+ and supports nested routers, configurable retries, and streaming responses to files
29
+ or IO. SSL verification, base URL scheme checks, and path traversal protection are
30
+ on by default. Optional ActiveSupport integration adds instrumentation and request
31
+ logging.
32
32
  email: dougyouch@gmail.com
33
33
  executables: []
34
34
  extensions: []
35
35
  extra_rdoc_files: []
36
36
  files:
37
- - ".cursor.json"
38
- - ".github/workflows/ci.yml"
39
- - ".gitignore"
40
- - ".rubocop.yml"
41
- - ".ruby-gemset"
42
- - ".ruby-version"
43
- - ARCHITECTURE.md
44
- - CLAUDE.md
45
- - Gemfile
46
- - Gemfile.lock
37
+ - CHANGELOG.md
47
38
  - LICENSE
48
39
  - README.md
49
- - client-api-builder.gemspec
50
- - examples/basic_auth_example_client.rb
51
- - examples/imdb_datasets_client.rb
52
- - examples/lorem_ipsum_client.rb
53
40
  - lib/client-api-builder.rb
54
41
  - lib/client_api_builder/active_support_log_subscriber.rb
55
42
  - lib/client_api_builder/active_support_notifications.rb
@@ -58,13 +45,12 @@ files:
58
45
  - lib/client_api_builder/query_params.rb
59
46
  - lib/client_api_builder/router.rb
60
47
  - lib/client_api_builder/section.rb
61
- - script/console
48
+ - lib/client_api_builder/version.rb
62
49
  homepage: https://github.com/dougyouch/client-api-builder
63
50
  licenses:
64
51
  - MIT
65
52
  metadata:
66
53
  rubygems_mfa_required: 'true'
67
- homepage_uri: https://github.com/dougyouch/client-api-builder
68
54
  source_code_uri: https://github.com/dougyouch/client-api-builder
69
55
  changelog_uri: https://github.com/dougyouch/client-api-builder/blob/master/CHANGELOG.md
70
56
  bug_tracker_uri: https://github.com/dougyouch/client-api-builder/issues
@@ -75,14 +61,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
75
61
  requirements:
76
62
  - - ">="
77
63
  - !ruby/object:Gem::Version
78
- version: '3.0'
64
+ version: '3.2'
79
65
  required_rubygems_version: !ruby/object:Gem::Requirement
80
66
  requirements:
81
67
  - - ">="
82
68
  - !ruby/object:Gem::Version
83
69
  version: '0'
84
70
  requirements: []
85
- rubygems_version: 3.6.2
71
+ rubygems_version: 4.0.20
86
72
  specification_version: 4
87
- summary: Build robust, secure API clients through declarative configuration
73
+ summary: Build Ruby HTTP API clients from declarative route definitions
88
74
  test_files: []
data/.cursor.json DELETED
@@ -1,29 +0,0 @@
1
- {
2
- "rules": [
3
- {
4
- "name": "Ruby spec file",
5
- "pattern": "^lib/(.+)\\.rb$",
6
- "target": "spec/${1}_spec.rb"
7
- },
8
- {
9
- "name": "Ruby implementation file",
10
- "pattern": "^spec/(.+)_spec\\.rb$",
11
- "target": "lib/${1}.rb"
12
- },
13
- {
14
- "name": "Related client_api_builder files",
15
- "pattern": "^(?:lib|spec)/client_api_builder/(.+)\\.rb$",
16
- "related": [
17
- "lib/client_api_builder/${1}.rb",
18
- "spec/client_api_builder/${1}_spec.rb"
19
- ]
20
- },
21
- {
22
- "name": "Main library file",
23
- "pattern": "^(?:lib|spec)/client_api_builder/.+\\.rb$",
24
- "related": [
25
- "lib/client-api-builder.rb"
26
- ]
27
- }
28
- ]
29
- }