client-api-builder 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00351abdc1f660fefd19687f75b696db8a1b9b16390ff6427706a8f5db64ee74
4
- data.tar.gz: 34dac63d4090971accab88566f4c217ccdb5a1d21ba000281ad5e2ea68bd74cb
3
+ metadata.gz: aac435317feb174db101e201b00464222bfc2df0f10601ac88e7897c3694be27
4
+ data.tar.gz: '08ad14efc0bff6c1f6e7fd4864e26c6f41853d72808fdc84b0e4781a0cfd229f'
5
5
  SHA512:
6
- metadata.gz: b34e2623702b08c9ac8156e8f0fe9da060636823492ec49bc34832e4e5bff09bb8ab4b3244cbba38bc49ec1a01a80ae30a484c8ec85fb8ae7a032131156278f5
7
- data.tar.gz: 4e164e2afd778cd2a6c2adb171c0ee6afec71eef925de9b61fe9b8c1b1d94a5faf2bd8fa624f6f691c4b7268d550e2e58dc82ef0c9b40470fe687026a5cef90f
6
+ metadata.gz: e28726cc8c9670553371ba62a3c2ea4d30a4edb5db413936b91810ab1b592b2b84e6dd0fd3bcac8ad47fa2fb2c1b5d57a6c0443cace95210b9cf203a5e783d43
7
+ data.tar.gz: ed7628816de37ff4e8f4a27f67520f277abac439cb6183eca0c98ce5a072a1bd8c0413a3db5bda145d75208b4272c598b89514ad80f6692a6cb300c255330b64
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.2](https://github.com/dougyouch/client-api-builder/compare/v0.7.1...v0.7.2) (2026-10-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **notifications:** report request exceptions to subscribers ([5c29e71](https://github.com/dougyouch/client-api-builder/commit/5c29e71b2ed23016fa68fce3b3aeda3cebdd1e21))
9
+ * **router:** check the base URL used for each request ([e9ea9c8](https://github.com/dougyouch/client-api-builder/commit/e9ea9c808569adf251247a849d6d5727cd3d2772))
10
+ * **router:** clear the previous response before each attempt ([861ae3e](https://github.com/dougyouch/client-api-builder/commit/861ae3e9fd73b45cbb7be33de09b3cf19ffae028))
11
+ * **router:** clear the response block when a route is redefined without one ([cca46c3](https://github.com/dougyouch/client-api-builder/commit/cca46c3e87176d37e953de1ec159b4cb3fd5f4a0))
12
+ * **router:** fill in every placeholder in query and body strings ([beb2d89](https://github.com/dougyouch/client-api-builder/commit/beb2d890f20423b792733919e4fded51fe47c5ee))
13
+ * **router:** only resolve symbols and procs for class-level headers and query params ([bfa29f1](https://github.com/dougyouch/client-api-builder/commit/bfa29f18d28e32eff4268b3dd3343162289140d1))
14
+ * **router:** reject route values that can't be compiled into the generated method ([2a5cdad](https://github.com/dougyouch/client-api-builder/commit/2a5cdad5c338ee8f05b0a3b3739c937f1fa48375))
15
+ * **router:** require a whole verb when detecting the HTTP method ([0bfa860](https://github.com/dougyouch/client-api-builder/commit/0bfa8601a454e8ecf27a496dd894a7a8c67a2351))
16
+ * **router:** stop route from modifying the caller's query and body ([faaee75](https://github.com/dougyouch/client-api-builder/commit/faaee756c81d258516422405ee3ac63fe9227771))
17
+ * **router:** treat a colon after a word as literal path text ([5dae4b0](https://github.com/dougyouch/client-api-builder/commit/5dae4b0bb3e0b850775f62541024d3e441cf87a1))
18
+ * **section:** define section methods with closures instead of generated source ([d9e23a6](https://github.com/dougyouch/client-api-builder/commit/d9e23a6c8bb010ed5a76993fd0ec6014cd0acb18))
19
+ * **stream:** check the response status before streaming the body ([eeb9e3b](https://github.com/dougyouch/client-api-builder/commit/eeb9e3b3895f4571bff147e5f75695e4f7324868))
20
+ * **stream:** reject only parent path segments in file names ([09c355d](https://github.com/dougyouch/client-api-builder/commit/09c355dc3c68dd06e07c8c9dfe003d7daf1153f6))
21
+
22
+ ### Upgrade Notes
23
+
24
+ * Per-request `headers:` and `query:` values and route arguments are now sent as given. A Symbol there (e.g. `query: { order: :desc }`) is sent as the value `desc` instead of calling a method of that name. Symbols and blocks given to the class-level `header` and `query_param` are still resolved on the client.
25
+ * HTTP method detection now needs the whole verb: route names where the verb runs into the next word (`posts`, `addresses`, `deleted_users`, `updates_feed`, `changelog`) now default to GET instead of POST, PUT or DELETE. Add `method:` to such routes to keep the old method.
26
+ * Redefining a route without a block, in the same class or a subclass, no longer keeps the previous or inherited response block. Pass the block again to keep it.
27
+ * A `:name` directly after a letter, digit, `_` or `}` is now literal path text, so `/v1/items:batchGet` works; a mid-word parameter such as `/items:id` no longer becomes an argument.
28
+ * Route `query:`/`body:` values must be strings, numbers, booleans, `nil`, hashes or arrays. `Range`, `Regexp`, `BigDecimal`, `Complex`, `Time` and other objects now raise an `ArgumentError` naming the route when the class loads; write them as a string or use a `'{method}'` placeholder.
29
+
3
30
  ## [0.7.1](https://github.com/dougyouch/client-api-builder/compare/v0.7.0...v0.7.1) (2026-10-03)
4
31
 
5
32
 
data/README.md CHANGED
@@ -119,7 +119,7 @@ Every generated method also accepts options that apply to that call only:
119
119
  ```ruby
120
120
  client.get_user(
121
121
  id: 1,
122
- headers: { 'X-Trace-Id' => 'abc' }, # merged over the class headers
122
+ headers: { 'X-Trace-Id' => 'abc' }, # merged over the class headers; nil removes one
123
123
  query: { expand: 'teams' }, # merged over the route's query params
124
124
  body: { name: 'Ann' }, # replaces the route's body
125
125
  connection_options: { read_timeout: 5 },
@@ -133,7 +133,7 @@ client.get_user(
133
133
 
134
134
  The Router detects the HTTP method from how the route name starts:
135
135
 
136
- | Name starts with | HTTP Method |
136
+ | Name starts with the word | HTTP Method |
137
137
  |------------------|-------------|
138
138
  | `post`, `create`, `add`, `insert` | POST |
139
139
  | `put`, `update`, `modify`, `change` | PUT |
@@ -141,7 +141,7 @@ The Router detects the HTTP method from how the route name starts:
141
141
  | `delete`, `remove`, `destroy` | DELETE |
142
142
  | anything else | GET |
143
143
 
144
- The match is on the start of the name only, so `address_lookup` is a POST. Pass `method:` when the name doesn't say it.
144
+ The verb must be the whole name or be followed by `_`: `create_user` and `delete` are detected, while names such as `posts`, `addresses`, `deleted_users` or `updates_feed` are GET. Pass `method:` when the name doesn't say it.
145
145
 
146
146
  ```ruby
147
147
  class MyApiClient
@@ -169,7 +169,9 @@ route :get_user, '/users/:id'
169
169
  # client.get_user(id: 1)
170
170
  ```
171
171
 
172
- `{name}` is filled from the client's own `name` method rather than an argument, which suits values like account IDs that are set once:
172
+ A colon directly after a letter, digit, `_` or `}` is literal text, so paths like `/v1/items:batchGet`, `/v1/{name}:cancel` or `/slots/12:30` need no escaping. Parameter names start with a letter or `_`.
173
+
174
+ `{name}` is filled from the client's own `name` method rather than an argument, which suits values like account IDs that are set once. If the route also has an argument called `name`, the argument is used instead (in sections, path values always come from the root client's method):
173
175
 
174
176
  ```ruby
175
177
  attr_accessor :account_id
@@ -178,7 +180,17 @@ route :get_invoices, '/accounts/{account_id}/invoices'
178
180
  # client.get_invoices
179
181
  ```
180
182
 
181
- The same `'{name}'` form works as a value inside `query:` and `body:`.
183
+ The same `{name}` form works inside `query:` and `body:` strings. A string that is exactly `'{name}'` passes the value through unchanged, so numbers, arrays and hashes keep their type. Placeholders within text are filled in as strings, and every one is used:
184
+
185
+ ```ruby
186
+ route :search_issues, '/issues', query: { q: 'author:{username} state:{state}' }
187
+ route :create_report, '/reports', body: { title: 'Report for {username}', limit: '{page_size}' }
188
+ # q=author:octocat state:open {"title":"Report for octocat","limit":50}
189
+ ```
190
+
191
+ `query:` and `body:` are compiled into the generated method when the class loads, so their fixed values must be strings, numbers, booleans, `nil`, hashes or arrays. Other objects (`Time`, `Date`, `BigDecimal`, `Range`, ...) raise an `ArgumentError` naming the route; pass them as an argument or a `'{method}'` placeholder, or write them as a string.
192
+
193
+ Any `{identifier}` in these strings is a placeholder; other text, including quotes and `#{...}`, is sent literally. Top-level String bodies (`body: '...'`) are always sent as is.
182
194
 
183
195
  Path values, from arguments and `{name}` alike, are percent-encoded so each stays a single segment: `get_file(name: 'a/b c')` requests `/files/a%2Fb%20c`. Only RFC 3986 unreserved characters (`A-Z a-z 0-9 - . _ ~`) are left as is. To change this, override `escape_path`:
184
196
 
@@ -232,6 +244,8 @@ class MyApiClient
232
244
  end
233
245
  ```
234
246
 
247
+ A symbol or block given to `header` or `query_param` is evaluated on the client for every request. Values passed when calling a route, including per-request `headers:` and `query:`, are always sent as given, so `client.list_items(sort: :asc)` sends `sort=asc`. Header values are sent as strings; setting one to `nil` for a request leaves it out.
248
+
235
249
  ### Request Body Formats
236
250
 
237
251
  Configure how request bodies are serialized:
@@ -308,7 +322,7 @@ user = client.users.get(id: 123)
308
322
  posts = client.posts.list
309
323
  ```
310
324
 
311
- A section is its own router class. It uses the parent's `base_url` unless it sets one, but headers, query params, connection options, retries and builders are not inherited, so declare the ones it needs inside the section. Symbol and block values, `{name}` path values, and response blocks are evaluated on the root client, so they can use its methods and state.
325
+ A section is its own router class. It uses the parent's `base_url` unless it sets one, but headers, query params, connection options, retries and builders are not inherited, so declare the ones it needs inside the section. Symbol and block values given to `header` and `query_param`, `{name}` path values, and response blocks are evaluated on the root client, so they can use its methods and state.
312
326
 
313
327
  ### Connection Options
314
328
 
@@ -412,6 +426,8 @@ end
412
426
 
413
427
  Streaming routes return the `Net::HTTPResponse`. Files are written in `wb` mode by default; pass `connection_options: { file_mode: 'ab' }` to append.
414
428
 
429
+ The status is checked against the route's expected response codes before any of the body is streamed. An error response raises `UnexpectedResponse` with the error body in `e.response.body`, and nothing is written to the file, IO or block; an existing file is left untouched.
430
+
415
431
  ### Response Handling
416
432
 
417
433
  Customize how responses are processed:
@@ -444,6 +460,8 @@ client.get_user(id: 1) { |data| data['name'] }
444
460
 
445
461
  Blocks run on the client, so they can call its methods and set its state. Empty response bodies return `nil`.
446
462
 
463
+ Redefining a route, in the same class or a subclass, replaces the whole definition including its block; without a block it goes back to the default handling. Pass the block again to keep it.
464
+
447
465
  ### Error Handling
448
466
 
449
467
  `ClientApiBuilder::UnexpectedResponse` is raised when the status code isn't expected (any non-2xx by default, or anything outside `expected_response_code(s)`) and when a response body isn't valid JSON. It carries the response:
@@ -482,6 +500,8 @@ puts client.total_request_time # Time in seconds
482
500
  puts client.request_attempts # Number of attempts (including retries)
483
501
  ```
484
502
 
503
+ These describe the latest attempt. If it failed, `response` is `nil` when no response arrived, and `request_options` is `nil` when the request couldn't be built.
504
+
485
505
  ### ActiveSupport Integration
486
506
 
487
507
  When ActiveSupport is loaded before your client class includes `ClientApiBuilder::Router`, every request is instrumented as a `client_api_builder.request` event:
@@ -492,9 +512,10 @@ ActiveSupport::Notifications.subscribe('client_api_builder.request') do |*args|
492
512
  event = ActiveSupport::Notifications::Event.new(*args)
493
513
  client = event.payload[:client]
494
514
 
495
- puts "#{client.request_options[:method]} #{client.request_options[:uri]}"
515
+ puts "#{client.request_options&.dig(:method)} #{client.request_options&.dig(:uri)}"
496
516
  puts "Status: #{client.response&.code}"
497
517
  puts "Duration: #{event.duration.round(2)}ms"
518
+ puts "Failed: #{event.payload[:exception_object].inspect}" if event.payload[:exception]
498
519
  end
499
520
 
500
521
  # Or use the built-in log subscriber
@@ -502,6 +523,13 @@ subscriber = ClientApiBuilder::ActiveSupportLogSubscriber.new(Rails.logger)
502
523
  subscriber.subscribe!
503
524
  ```
504
525
 
526
+ An event fires for every attempt, including failed ones. When an attempt raises, the payload also holds `:exception` (`[class name, message]`) and `:exception_object`, following the usual ActiveSupport convention, and `request_options` or `response` may be `nil`. The built-in subscriber logs lines like:
527
+
528
+ ```
529
+ GET https://api.example.com/users/123[200] took 45ms
530
+ GET https://api.example.com/users/123[UNKNOWN] took 5003ms (Net::ReadTimeout: Net::ReadTimeout)
531
+ ```
532
+
505
533
  Separately, `ClientApiBuilder.logger` receives every exception raised during a request attempt, including ones that are retried:
506
534
 
507
535
  ```ruby
@@ -540,7 +568,7 @@ HTTPS connections verify SSL certificates using `OpenSSL::SSL::VERIFY_PEER` and
540
568
 
541
569
  ### SSRF Protection
542
570
 
543
- Base URLs 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:
544
572
 
545
573
  ```ruby
546
574
  class MyApiClient
@@ -553,20 +581,27 @@ class MyApiClient
553
581
  end
554
582
  ```
555
583
 
584
+ The URL actually used is checked again on every request, so a `base_url` method defined on the client (for example, a per-tenant URL) gets the same check. A client with no base URL at all raises `ArgumentError: no base_url configured for MyApiClient` instead of failing inside Net::HTTP.
585
+
556
586
  ### Path Value Encoding
557
587
 
558
588
  Values inserted into a route's path are percent-encoded, so input such as `../admin` or `a/b?x=1` can't add path segments or a query string to the request.
559
589
 
560
590
  ### Path Traversal Protection
561
591
 
562
- File streaming rejects any path containing `..` or a null byte:
592
+ File streaming rejects a path with a `..` segment or a null byte, so a file name built from untrusted input can't climb out of the directory you put it in:
563
593
 
564
594
  ```ruby
565
- # These will raise ArgumentError
566
- client.download_file(id: 1, file: '/tmp/../etc/passwd')
595
+ # These raise ArgumentError
596
+ client.download_file(id: 1, file: "/downloads/#{'../etc/passwd'}")
567
597
  client.download_file(id: 1, file: "/tmp/file\0.txt")
598
+
599
+ # Names that only contain dots are fine
600
+ client.download_file(id: 1, file: '/downloads/report..v2.csv')
568
601
  ```
569
602
 
603
+ Absolute paths and symlinks are not restricted, since only your code knows where files may go. If untrusted input can supply the whole path, validate it before passing it as `file:`.
604
+
570
605
  ### Safe File Modes
571
606
 
572
607
  Only safe file modes are allowed for streaming to files: `w`, `wb`, `a`, `ab`, `w+`, `wb+`, `a+`, `ab+`.
@@ -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
@@ -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
@@ -25,17 +25,31 @@ module ClientApiBuilder
25
25
  # Allowed URL schemes for base_url to prevent SSRF attacks
26
26
  ALLOWED_URL_SCHEMES = %w[http https].freeze
27
27
 
28
- # Deep duplicates a hash to prevent shared mutable state
29
- def deep_dup_hash(hash)
30
- hash.transform_values do |value|
31
- case value
32
- when Hash then deep_dup_hash(value)
33
- when Array then value.map { |v| v.is_a?(Hash) ? deep_dup_hash(v) : v }
34
- else value
35
- 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
36
46
  end
37
47
  end
38
48
 
49
+ def deep_dup_hash(hash)
50
+ deep_dup(hash)
51
+ end
52
+
39
53
  def default_options
40
54
  {
41
55
  base_url: nil,
@@ -50,7 +64,7 @@ module ClientApiBuilder
50
64
  }.freeze
51
65
  end
52
66
 
53
- # tracks the proc used to handle responses
67
+ # tracks the proc used to handle responses; nil clears it
54
68
  def add_response_proc(method_name, proc)
55
69
  response_procs = deep_dup_hash(default_options[:response_procs])
56
70
  response_procs[method_name] = proc
@@ -74,10 +88,11 @@ module ClientApiBuilder
74
88
  # Validates that base_url uses an allowed scheme
75
89
  def validate_base_url!(url)
76
90
  uri = URI.parse(url.to_s)
77
- return if ALLOWED_URL_SCHEMES.include?(uri.scheme&.downcase)
78
-
79
- allowed = ALLOWED_URL_SCHEMES.join(', ')
80
- 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?
81
96
  rescue URI::InvalidURIError => e
82
97
  raise ArgumentError, "Invalid base_url: #{e.message}"
83
98
  end
@@ -170,15 +185,17 @@ module ClientApiBuilder
170
185
  end
171
186
  end
172
187
 
188
+ # The verb must be the whole name or be followed by '_', so create_user is a POST
189
+ # but names such as posts or deleted_users stay GET.
173
190
  def auto_detect_http_method(method_name)
174
191
  case method_name.to_s
175
- when /^(?:post|create|add|insert)/i
192
+ when /\A(?:post|create|add|insert)(?:_|\z)/i
176
193
  :post
177
- when /^(?:put|update|modify|change)/i
194
+ when /\A(?:put|update|modify|change)(?:_|\z)/i
178
195
  :put
179
- when /^(?:patch)/i
196
+ when /\Apatch(?:_|\z)/i
180
197
  :patch
181
- when /^(?:delete|remove|destroy)/i
198
+ when /\A(?:delete|remove|destroy)(?:_|\z)/i
182
199
  :delete
183
200
  else
184
201
  :get
@@ -192,48 +209,56 @@ module ClientApiBuilder
192
209
  REQUIRED_BODY_HTTP_METHODS.include?(http_method)
193
210
  end
194
211
 
212
+ # Replaces argument symbols and '{name}' strings in a query/body hash, in place, with
213
+ # CodeSnippets; returns the argument names found
195
214
  def get_hash_arguments(hsh)
196
215
  arguments = []
197
- hsh.each do |k, v|
198
- case v
199
- when Symbol
200
- hsh[k] = "__||#{v}||__"
201
- arguments << v
202
- when Hash
203
- arguments += get_hash_arguments(v)
204
- when Array
205
- arguments += get_array_arguments(v)
206
- when String
207
- # Use match with block form to avoid thread-unsafe $1 global variable
208
- if (match = v.match(/\{([a-z0-9_]+)\}/i))
209
- hsh[k] = "__||#{match[1]}||__"
210
- end
211
- end
212
- end
216
+ hsh.each { |key, value| hsh[key] = argument_code(value, arguments) }
213
217
  arguments
214
218
  end
215
219
 
220
+ # Same as get_hash_arguments, for an array
216
221
  def get_array_arguments(list)
217
222
  arguments = []
218
- list.each_with_index do |v, idx|
219
- case v
220
- when Symbol
221
- list[idx] = "__||#{v}||__"
222
- arguments << v
223
- when Hash
224
- arguments += get_hash_arguments(v)
225
- when Array
226
- arguments += get_array_arguments(v)
227
- when String
228
- # Use match with block form to avoid thread-unsafe $1 global variable
229
- if (match = v.match(/\{([a-z0-9_]+)\}/i))
230
- list[idx] = "__||#{match[1]}||__"
231
- end
232
- end
233
- end
223
+ list.each_with_index { |value, idx| list[idx] = argument_code(value, arguments) }
234
224
  arguments
235
225
  end
236
226
 
227
+ def argument_code(value, arguments)
228
+ case value
229
+ when Symbol
230
+ arguments << value
231
+ CodeSnippet.new(value.to_s)
232
+ when Hash
233
+ arguments.concat(get_hash_arguments(value))
234
+ value
235
+ when Array
236
+ arguments.concat(get_array_arguments(value))
237
+ value
238
+ when String
239
+ string_template_code(value)
240
+ else
241
+ value
242
+ end
243
+ end
244
+
245
+ # A string that is exactly '{name}' passes the value through unchanged (keeping its type);
246
+ # placeholders within text are interpolated into the string. Strings without placeholders
247
+ # are returned as is.
248
+ def string_template_code(str)
249
+ return str unless str.match?(PLACEHOLDER)
250
+
251
+ whole = str.match(WHOLE_PLACEHOLDER)
252
+ return CodeSnippet.new(whole[1]) if whole
253
+
254
+ parts = str.split(/(\{[a-z0-9_]+\})/i).map do |part|
255
+ placeholder = part.match(WHOLE_PLACEHOLDER)
256
+ # inspect escapes quotes, backslashes and '#{' so the text stays literal
257
+ placeholder ? "\#{#{placeholder[1]}}" : part.inspect[1..-2]
258
+ end
259
+ CodeSnippet.new("\"#{parts.join}\"")
260
+ end
261
+
237
262
  # returns a list of arguments to add to the route method
238
263
  def get_arguments(value)
239
264
  case value
@@ -275,7 +300,7 @@ module ClientApiBuilder
275
300
  end
276
301
 
277
302
  path_arguments = []
278
- path = path.gsub(/:([a-z0-9_]+)/i) do |_match|
303
+ path = path.gsub(PATH_PARAMETER) do |_match|
279
304
  param_name = Regexp.last_match(1)
280
305
  path_arguments << param_name
281
306
  "#\{escape_path(#{param_name})}"
@@ -293,7 +318,7 @@ module ClientApiBuilder
293
318
 
294
319
  pairs = value.map do |k, v|
295
320
  key_code = case k
296
- when Symbol then "#{k}: "
321
+ when Symbol then k.match?(RouteValueValidator::ARGUMENT_NAME) ? "#{k}: " : "#{k.inspect} => "
297
322
  when String then "#{k.inspect} => "
298
323
  else "#{value_to_code(k)} => "
299
324
  end
@@ -306,17 +331,19 @@ module ClientApiBuilder
306
331
  'nil'
307
332
  when TrueClass, FalseClass
308
333
  value.to_s
334
+ when CodeSnippet
335
+ value.code
309
336
  else
310
337
  value.inspect
311
338
  end
312
339
  end
313
340
 
341
+ # get_arguments rewrites values in place, so work on a copy of the caller's query/body
314
342
  def build_query_code(options)
315
343
  if options[:query]
316
- query_arguments = get_arguments(options[:query])
317
- str = value_to_code(options[:query])
318
- str = str.gsub(/"__\|\|(.+?)\|\|__"/) { Regexp.last_match(1) }
319
- [str, query_arguments.map(&:to_s)]
344
+ query = deep_dup(options[:query])
345
+ query_arguments = get_arguments(query)
346
+ [value_to_code(query), query_arguments.map(&:to_s)]
320
347
  else
321
348
  ['nil', []]
322
349
  end
@@ -324,10 +351,9 @@ module ClientApiBuilder
324
351
 
325
352
  def build_body_code(options, has_body_param)
326
353
  if options[:body]
327
- body_arguments = get_arguments(options[:body])
328
- str = value_to_code(options[:body])
329
- str = str.gsub(/"__\|\|(.+?)\|\|__"/) { Regexp.last_match(1) }
330
- [str, body_arguments.map(&:to_s), false]
354
+ body = deep_dup(options[:body])
355
+ body_arguments = get_arguments(body)
356
+ [value_to_code(body), body_arguments.map(&:to_s), false]
331
357
  else
332
358
  [has_body_param ? 'body' : 'nil', [], has_body_param]
333
359
  end
@@ -352,18 +378,26 @@ module ClientApiBuilder
352
378
  args + ['**__options__', '&block']
353
379
  end
354
380
 
355
- def generate_request_call_code(options, stream_param)
381
+ def generate_request_call_code(options, stream_param, expected_response_codes)
356
382
  code = " @request_options[:#{stream_param}] = #{stream_param}\n" if stream_param
357
383
  code ||= ''
384
+ validator = stream_validator_code(expected_response_codes)
358
385
 
359
386
  code + case options[:stream]
360
- when true, :file then " @response = stream_to_file(**@request_options)\n"
361
- when :io then " @response = stream_to_io(**@request_options)\n"
362
- when :block then " @response = stream(**@request_options, &block)\n"
387
+ when true, :file then " @response = stream_to_file(**@request_options, #{validator})\n"
388
+ when :io then " @response = stream_to_io(**@request_options, #{validator})\n"
389
+ when :block then " @response = stream(**@request_options, #{validator}, &block)\n"
363
390
  else " @response = request(**@request_options)\n"
364
391
  end
365
392
  end
366
393
 
394
+ # Streaming routes check the status before the body is streamed, using the same
395
+ # expected_response_code! as other routes, so error bodies never reach the file, IO or block
396
+ def stream_validator_code(expected_response_codes)
397
+ 'validate_response: ->(response) { ' \
398
+ "expected_response_code!(response, #{expected_response_codes.inspect}, __options__) }"
399
+ end
400
+
367
401
  def generate_response_handling_code(options)
368
402
  if options[:stream] || options[:return] == :response
369
403
  " @response\n"
@@ -380,6 +414,9 @@ module ClientApiBuilder
380
414
  raise ArgumentError, "Invalid method name: #{method_name.inspect}"
381
415
  end
382
416
 
417
+ RouteValueValidator.validate!(method_name, :query, options[:query])
418
+ RouteValueValidator.validate!(method_name, :body, options[:body])
419
+
383
420
  http_method = options[:method] || auto_detect_http_method(method_name)
384
421
  path, path_arguments = process_route_path(path)
385
422
  has_body_param = options[:body].nil? && requires_body?(http_method, options)
@@ -413,7 +450,7 @@ module ClientApiBuilder
413
450
  code += " __connection_options__ = build_connection_options(__options__)\n"
414
451
  code += " @request_options = {method: #{ctx[:http_method].inspect}, uri: __uri__, body: __body__, " \
415
452
  "headers: __headers__, connection_options: __connection_options__}\n"
416
- code += generate_request_call_code(ctx[:options], ctx[:stream_param])
453
+ code += generate_request_call_code(ctx[:options], ctx[:stream_param], ctx[:expected_response_codes])
417
454
  "#{code}end\n\n"
418
455
  end
419
456
 
@@ -431,8 +468,10 @@ module ClientApiBuilder
431
468
  "#{code}end\n"
432
469
  end
433
470
 
471
+ # A route definition is complete: redefining a route without a block also clears the
472
+ # previous block, including one inherited from a parent class
434
473
  def route(method_name, path, options = {}, &block)
435
- add_response_proc(method_name, block) if block
474
+ add_response_proc(method_name, block)
436
475
 
437
476
  class_eval generate_route_code(method_name, path, options), __FILE__, __LINE__
438
477
  end
@@ -442,24 +481,12 @@ module ClientApiBuilder
442
481
  self.class.base_url
443
482
  end
444
483
 
484
+ # Class-level headers may be method names or blocks; per-request headers are used as given.
485
+ # Values are converted to strings, as Net::HTTP requires; nil values are left out of the request.
445
486
  def build_headers(options)
446
- headers = {}
447
-
448
- add_header_proc = proc do |name, value|
449
- headers[name] =
450
- if value.is_a?(Proc)
451
- root_router.instance_eval(&value)
452
- elsif value.is_a?(Symbol)
453
- root_router.send(value)
454
- else
455
- value
456
- end
457
- end
458
-
459
- self.class.default_headers.each(&add_header_proc)
460
- options[:headers]&.each(&add_header_proc)
461
-
462
- headers
487
+ headers = self.class.default_headers.transform_values { |value| resolve_config_value(value) }
488
+ headers.merge!(options[:headers]) if options[:headers]
489
+ headers.transform_values { |value| value&.to_s }
463
490
  end
464
491
 
465
492
  def build_connection_options(options)
@@ -470,27 +497,26 @@ module ClientApiBuilder
470
497
  end
471
498
  end
472
499
 
500
+ # Class-level query params may be method names or blocks; values from route arguments
501
+ # and per-request options are sent as given.
473
502
  def build_query(query, options)
474
- query_params = {}
475
-
476
- add_query_param_proc = proc do |name, value|
477
- query_params[name] =
478
- if value.is_a?(Proc)
479
- root_router.instance_eval(&value)
480
- elsif value.is_a?(Symbol)
481
- root_router.send(value)
482
- else
483
- value
484
- end
485
- end
486
-
487
- self.class.default_query_params.each(&add_query_param_proc)
488
- query&.each(&add_query_param_proc)
489
- options[:query]&.each(&add_query_param_proc)
503
+ query_params = self.class.default_query_params.transform_values { |value| resolve_config_value(value) }
504
+ query_params.merge!(query) if query
505
+ query_params.merge!(options[:query]) if options[:query]
490
506
 
491
507
  query_params.empty? ? nil : self.class.build_query(self, query_params)
492
508
  end
493
509
 
510
+ # Resolves a class-level header or query_param value: a Symbol calls that method and a
511
+ # Proc is evaluated, both on the root router; anything else is used as is.
512
+ def resolve_config_value(value)
513
+ case value
514
+ when Proc then root_router.instance_eval(&value)
515
+ when Symbol then root_router.send(value)
516
+ else value
517
+ end
518
+ end
519
+
494
520
  def build_body(body, options)
495
521
  body = options[:body] if options.key?(:body)
496
522
 
@@ -502,8 +528,7 @@ module ClientApiBuilder
502
528
 
503
529
  def build_uri(path, query, options)
504
530
  # Properly join base_url and path to handle missing/extra slashes
505
- base = base_url.to_s
506
- base = base.chomp('/') if base.end_with?('/')
531
+ base = validated_base_url.chomp('/')
507
532
  path = path.to_s
508
533
  path = "/#{path}" unless path.start_with?('/')
509
534
 
@@ -512,6 +537,19 @@ module ClientApiBuilder
512
537
  uri
513
538
  end
514
539
 
540
+ # The base URL used for this request, checked every time so a base_url method defined on
541
+ # the client gets the same scheme and host check as the class-level base_url
542
+ def validated_base_url
543
+ url = base_url.to_s
544
+ if url.empty?
545
+ raise ArgumentError, "no base_url configured for #{self.class.name || self.class.inspect}; set one with " \
546
+ "base_url 'https://api.example.com' or define a base_url method"
547
+ end
548
+
549
+ self.class.validate_base_url!(url)
550
+ url
551
+ end
552
+
515
553
  def expected_response_code!(response, expected_response_codes, _options)
516
554
  return if expected_response_codes.empty? && response.is_a?(Net::HTTPSuccess)
517
555
  return if expected_response_codes.include?(response.code)
@@ -594,6 +632,9 @@ module ClientApiBuilder
594
632
 
595
633
  def request_wrapper(options, &block)
596
634
  retry_request(options) do
635
+ # Clear the previous attempt's state so a failed attempt never reports an earlier response
636
+ @request_options = nil
637
+ @response = nil
597
638
  instrument_request(&block)
598
639
  end
599
640
  end
@@ -8,7 +8,13 @@ module ClientApiBuilder
8
8
  end
9
9
 
10
10
  module ClassMethods
11
+ SECTION_NAME = /\A[a-z_][a-z0-9_]*\z/i
12
+
13
+ # Defines <name>_router (the section's NestedRouter class) and <name> (its router for a
14
+ # client instance) with closures, so anonymous client classes and any option values work.
11
15
  def section(name, nested_router_options = {}, &)
16
+ raise ArgumentError, "Invalid section name: #{name.inspect}" unless name.to_s.match?(SECTION_NAME)
17
+
12
18
  kls = InheritanceHelper::ClassBuilder::Utils.create_class(
13
19
  self,
14
20
  name,
@@ -18,16 +24,22 @@ module ClientApiBuilder
18
24
  &
19
25
  )
20
26
 
21
- 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.1'
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.1
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