client-api-builder 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +53 -0
- data/.rubocop.yml +79 -0
- data/ARCHITECTURE.md +161 -86
- data/CLAUDE.md +92 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +46 -39
- data/README.md +427 -92
- data/client-api-builder.gemspec +20 -4
- data/examples/basic_auth_example_client.rb +6 -5
- data/examples/imdb_datasets_client.rb +2 -0
- data/examples/lorem_ipsum_client.rb +3 -1
- data/lib/client-api-builder.rb +1 -0
- data/lib/client_api_builder/active_support_log_subscriber.rb +1 -0
- data/lib/client_api_builder/active_support_notifications.rb +8 -6
- data/lib/client_api_builder/nested_router.rb +3 -3
- data/lib/client_api_builder/net_http_request.rb +37 -5
- data/lib/client_api_builder/query_params.rb +9 -3
- data/lib/client_api_builder/router.rb +210 -125
- data/lib/client_api_builder/section.rb +11 -11
- data/script/console +1 -1
- metadata +20 -10
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'inheritance-helper'
|
|
3
4
|
require 'json'
|
|
4
5
|
|
|
@@ -14,11 +15,25 @@ module ClientApiBuilder
|
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
module ClassMethods
|
|
17
|
-
REQUIRED_BODY_HTTP_METHODS = [
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
]
|
|
18
|
+
REQUIRED_BODY_HTTP_METHODS = %i[
|
|
19
|
+
post
|
|
20
|
+
put
|
|
21
|
+
patch
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Allowed URL schemes for base_url to prevent SSRF attacks
|
|
25
|
+
ALLOWED_URL_SCHEMES = %w[http https].freeze
|
|
26
|
+
|
|
27
|
+
# Deep duplicates a hash to prevent shared mutable state
|
|
28
|
+
def deep_dup_hash(hash)
|
|
29
|
+
hash.transform_values do |value|
|
|
30
|
+
case value
|
|
31
|
+
when Hash then deep_dup_hash(value)
|
|
32
|
+
when Array then value.map { |v| v.is_a?(Hash) ? deep_dup_hash(v) : v }
|
|
33
|
+
else value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
22
37
|
|
|
23
38
|
def default_options
|
|
24
39
|
{
|
|
@@ -36,7 +51,7 @@ module ClientApiBuilder
|
|
|
36
51
|
|
|
37
52
|
# tracks the proc used to handle responses
|
|
38
53
|
def add_response_proc(method_name, proc)
|
|
39
|
-
response_procs = default_options[:response_procs]
|
|
54
|
+
response_procs = deep_dup_hash(default_options[:response_procs])
|
|
40
55
|
response_procs[method_name] = proc
|
|
41
56
|
add_value_to_class_method(:default_options, response_procs: response_procs)
|
|
42
57
|
end
|
|
@@ -47,12 +62,24 @@ module ClientApiBuilder
|
|
|
47
62
|
end
|
|
48
63
|
|
|
49
64
|
# set/get base url
|
|
65
|
+
# Validates URL scheme to prevent SSRF attacks
|
|
50
66
|
def base_url(url = nil)
|
|
51
67
|
return default_options[:base_url] unless url
|
|
52
68
|
|
|
69
|
+
validate_base_url!(url)
|
|
53
70
|
add_value_to_class_method(:default_options, base_url: url)
|
|
54
71
|
end
|
|
55
72
|
|
|
73
|
+
# Validates that base_url uses an allowed scheme
|
|
74
|
+
def validate_base_url!(url)
|
|
75
|
+
uri = URI.parse(url.to_s)
|
|
76
|
+
return if ALLOWED_URL_SCHEMES.include?(uri.scheme&.downcase)
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, "Invalid base_url scheme: #{uri.scheme.inspect}. Allowed: #{ALLOWED_URL_SCHEMES.join(', ')}"
|
|
79
|
+
rescue URI::InvalidURIError => e
|
|
80
|
+
raise ArgumentError, "Invalid base_url: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
56
83
|
# set the builder to :to_json, :to_query, :query_params or specify a proc to handle building the request body payload
|
|
57
84
|
# or get the body builder
|
|
58
85
|
def body_builder(builder = nil, &block)
|
|
@@ -71,14 +98,14 @@ module ClientApiBuilder
|
|
|
71
98
|
|
|
72
99
|
# add a request header
|
|
73
100
|
def header(name, value = nil, &block)
|
|
74
|
-
headers = default_options[:headers]
|
|
101
|
+
headers = deep_dup_hash(default_options[:headers])
|
|
75
102
|
headers[name] = value || block
|
|
76
103
|
add_value_to_class_method(:default_options, headers: headers)
|
|
77
104
|
end
|
|
78
105
|
|
|
79
106
|
# set a connection_option, specific to Net::HTTP
|
|
80
107
|
def connection_option(name, value)
|
|
81
|
-
connection_options = default_options[:connection_options]
|
|
108
|
+
connection_options = deep_dup_hash(default_options[:connection_options])
|
|
82
109
|
connection_options[name] = value
|
|
83
110
|
add_value_to_class_method(:default_options, connection_options: connection_options)
|
|
84
111
|
end
|
|
@@ -93,7 +120,7 @@ module ClientApiBuilder
|
|
|
93
120
|
|
|
94
121
|
# add a query param to all requests
|
|
95
122
|
def query_param(name, value = nil, &block)
|
|
96
|
-
query_params = default_options[:query_params]
|
|
123
|
+
query_params = deep_dup_hash(default_options[:query_params])
|
|
97
124
|
query_params[name] = value || block
|
|
98
125
|
add_value_to_class_method(:default_options, query_params: query_params)
|
|
99
126
|
end
|
|
@@ -175,7 +202,10 @@ module ClientApiBuilder
|
|
|
175
202
|
when Array
|
|
176
203
|
arguments += get_array_arguments(v)
|
|
177
204
|
when String
|
|
178
|
-
|
|
205
|
+
# Use match with block form to avoid thread-unsafe $1 global variable
|
|
206
|
+
if (match = v.match(/\{([a-z0-9_]+)\}/i))
|
|
207
|
+
hsh[k] = "__||#{match[1]}||__"
|
|
208
|
+
end
|
|
179
209
|
end
|
|
180
210
|
end
|
|
181
211
|
arguments
|
|
@@ -193,7 +223,10 @@ module ClientApiBuilder
|
|
|
193
223
|
when Array
|
|
194
224
|
arguments += get_array_arguments(v)
|
|
195
225
|
when String
|
|
196
|
-
|
|
226
|
+
# Use match with block form to avoid thread-unsafe $1 global variable
|
|
227
|
+
if (match = v.match(/\{([a-z0-9_]+)\}/i))
|
|
228
|
+
list[idx] = "__||#{match[1]}||__"
|
|
229
|
+
end
|
|
197
230
|
end
|
|
198
231
|
end
|
|
199
232
|
arguments
|
|
@@ -212,140 +245,165 @@ module ClientApiBuilder
|
|
|
212
245
|
end
|
|
213
246
|
|
|
214
247
|
def get_instance_method(var)
|
|
215
|
-
|
|
248
|
+
"#\{escape_path(#{var})}"
|
|
216
249
|
end
|
|
217
250
|
|
|
218
|
-
|
|
251
|
+
# Thread-local storage key for namespaces to ensure thread safety
|
|
252
|
+
NAMESPACE_THREAD_KEY = :client_api_builder_namespaces
|
|
253
|
+
|
|
219
254
|
def namespaces
|
|
220
|
-
|
|
255
|
+
Thread.current[NAMESPACE_THREAD_KEY] ||= []
|
|
221
256
|
end
|
|
222
257
|
|
|
223
258
|
# a namespace is a top level path to apply to all routes within the namespace block
|
|
224
259
|
def namespace(name)
|
|
225
260
|
namespaces << name
|
|
226
261
|
yield
|
|
262
|
+
ensure
|
|
263
|
+
# Always pop the namespace, even if an exception occurs during yield
|
|
227
264
|
namespaces.pop
|
|
228
265
|
end
|
|
229
266
|
|
|
230
|
-
def
|
|
231
|
-
http_method = options[:method] || auto_detect_http_method(method_name)
|
|
232
|
-
|
|
267
|
+
def process_route_path(path)
|
|
233
268
|
path = namespaces.join + path
|
|
234
269
|
|
|
235
|
-
# instance method
|
|
236
|
-
path.gsub
|
|
237
|
-
get_instance_method(
|
|
270
|
+
# instance method - use block parameter to avoid thread-unsafe $1
|
|
271
|
+
path = path.gsub(/\{([a-z0-9_]+)\}/i) do |_match|
|
|
272
|
+
get_instance_method(Regexp.last_match(1))
|
|
238
273
|
end
|
|
239
274
|
|
|
240
275
|
path_arguments = []
|
|
241
|
-
path.gsub
|
|
242
|
-
|
|
243
|
-
|
|
276
|
+
path = path.gsub(/:([a-z0-9_]+)/i) do |_match|
|
|
277
|
+
param_name = Regexp.last_match(1)
|
|
278
|
+
path_arguments << param_name
|
|
279
|
+
"#\{escape_path(#{param_name})}"
|
|
244
280
|
end
|
|
245
281
|
|
|
246
|
-
|
|
282
|
+
[path, path_arguments]
|
|
283
|
+
end
|
|
247
284
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
285
|
+
def build_query_code(options)
|
|
286
|
+
if options[:query]
|
|
287
|
+
query_arguments = get_arguments(options[:query])
|
|
288
|
+
str = options[:query].inspect
|
|
289
|
+
str = str.gsub(/"__\|\|(.+?)\|\|__"/) { Regexp.last_match(1) }
|
|
290
|
+
[str, query_arguments.map(&:to_s)]
|
|
291
|
+
else
|
|
292
|
+
['nil', []]
|
|
293
|
+
end
|
|
294
|
+
end
|
|
258
295
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
end
|
|
296
|
+
def build_body_code(options, has_body_param)
|
|
297
|
+
if options[:body]
|
|
298
|
+
body_arguments = get_arguments(options[:body])
|
|
299
|
+
str = options[:body].inspect
|
|
300
|
+
str = str.gsub(/"__\|\|(.+?)\|\|__"/) { Regexp.last_match(1) }
|
|
301
|
+
[str, body_arguments.map(&:to_s), false]
|
|
302
|
+
else
|
|
303
|
+
[has_body_param ? 'body' : 'nil', [], has_body_param]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
270
306
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
307
|
+
def extract_expected_response_codes(options)
|
|
308
|
+
codes = options[:expected_response_codes] || (options[:expected_response_code] ? [options[:expected_response_code]] : [])
|
|
309
|
+
codes.map(&:to_s)
|
|
310
|
+
end
|
|
275
311
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
stream_param
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
312
|
+
def determine_stream_param(options)
|
|
313
|
+
case options[:stream]
|
|
314
|
+
when true, :file then :file
|
|
315
|
+
when :io then :io
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def build_method_args(named_arguments, has_body_param, stream_param)
|
|
320
|
+
args = named_arguments.map { |arg_name| "#{arg_name}:" }
|
|
321
|
+
args += ['body:'] if has_body_param
|
|
322
|
+
args += ["#{stream_param}:"] if stream_param
|
|
323
|
+
args + ['**__options__', '&block']
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def generate_request_call_code(options, stream_param)
|
|
327
|
+
code = " @request_options[:#{stream_param}] = #{stream_param}\n" if stream_param
|
|
328
|
+
code ||= ''
|
|
329
|
+
|
|
330
|
+
code + case options[:stream]
|
|
331
|
+
when true, :file then " @response = stream_to_file(**@request_options)\n"
|
|
332
|
+
when :io then " @response = stream_to_io(**@request_options)\n"
|
|
333
|
+
when :block then " @response = stream(**@request_options, &block)\n"
|
|
334
|
+
else " @response = request(**@request_options)\n"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
294
337
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
338
|
+
def generate_response_handling_code(options)
|
|
339
|
+
if options[:stream] || options[:return] == :response
|
|
340
|
+
" @response\n"
|
|
341
|
+
elsif options[:return] == :body
|
|
342
|
+
" @response.body\n"
|
|
343
|
+
else
|
|
344
|
+
" handle_response(@response, __options__, &block)\n"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def generate_route_code(method_name, path, options = {})
|
|
349
|
+
# Validate method_name to prevent code injection
|
|
350
|
+
raise ArgumentError, "Invalid method name: #{method_name.inspect}" unless method_name.to_s.match?(/\A[a-z_][a-z0-9_]*\z/i)
|
|
299
351
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
352
|
+
http_method = options[:method] || auto_detect_http_method(method_name)
|
|
353
|
+
path, path_arguments = process_route_path(path)
|
|
354
|
+
has_body_param = options[:body].nil? && requires_body?(http_method, options)
|
|
355
|
+
|
|
356
|
+
query, query_arguments = build_query_code(options)
|
|
357
|
+
body, body_arguments, has_body_param = build_body_code(options, has_body_param)
|
|
358
|
+
|
|
359
|
+
named_arguments = (path_arguments + query_arguments + body_arguments).uniq
|
|
360
|
+
expected_response_codes = extract_expected_response_codes(options)
|
|
361
|
+
stream_param = determine_stream_param(options)
|
|
362
|
+
method_args = build_method_args(named_arguments, has_body_param, stream_param)
|
|
363
|
+
|
|
364
|
+
route_context = {
|
|
365
|
+
method_name: method_name, method_args: method_args, path: path,
|
|
366
|
+
query: query, body: body, http_method: http_method,
|
|
367
|
+
options: options, stream_param: stream_param,
|
|
368
|
+
expected_response_codes: expected_response_codes
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
generate_raw_response_method(route_context) + generate_wrapper_method(route_context)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def generate_raw_response_method(ctx)
|
|
375
|
+
code = "def #{ctx[:method_name]}_raw_response(#{ctx[:method_args].join(', ')})\n"
|
|
376
|
+
code += " __path__ = \"#{ctx[:path]}\"\n"
|
|
377
|
+
code += " __query__ = #{ctx[:query]}\n"
|
|
378
|
+
code += " __body__ = #{ctx[:body]}\n"
|
|
304
379
|
code += " __uri__ = build_uri(__path__, __query__, __options__)\n"
|
|
305
380
|
code += " __body__ = build_body(__body__, __options__)\n"
|
|
306
381
|
code += " __headers__ = build_headers(__options__)\n"
|
|
307
382
|
code += " __connection_options__ = build_connection_options(__options__)\n"
|
|
308
|
-
code += " @request_options = {method: #{http_method.inspect}, uri: __uri__, body: __body__,
|
|
309
|
-
|
|
383
|
+
code += " @request_options = {method: #{ctx[:http_method].inspect}, uri: __uri__, body: __body__, " \
|
|
384
|
+
"headers: __headers__, connection_options: __connection_options__}\n"
|
|
385
|
+
code += generate_request_call_code(ctx[:options], ctx[:stream_param])
|
|
386
|
+
code + "end\n\n"
|
|
387
|
+
end
|
|
310
388
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
:file
|
|
314
|
-
code += " @response = stream_to_file(**@request_options)\n"
|
|
315
|
-
when :io
|
|
316
|
-
code += " @response = stream_to_io(**@request_options)\n"
|
|
317
|
-
when :block
|
|
318
|
-
code += " @response = stream(**@request_options, &block)\n"
|
|
319
|
-
else
|
|
320
|
-
code += " @response = request(**@request_options)\n"
|
|
321
|
-
end
|
|
322
|
-
code += "end\n"
|
|
323
|
-
code += "\n"
|
|
389
|
+
def generate_wrapper_method(ctx)
|
|
390
|
+
raw_call_args = ctx[:method_args].map { |a| a =~ /:$/ ? "#{a} #{a.sub(':', '')}" : a }.join(', ')
|
|
324
391
|
|
|
325
|
-
code
|
|
392
|
+
code = "def #{ctx[:method_name]}(#{ctx[:method_args].join(', ')})\n"
|
|
326
393
|
code += " request_wrapper(__options__) do\n"
|
|
327
|
-
code += " block ||= self.class.get_response_proc(#{method_name.inspect})\n"
|
|
328
|
-
code += " __expected_response_codes__ = #{expected_response_codes.inspect}\n"
|
|
329
|
-
code += " #{method_name}_raw_response(
|
|
394
|
+
code += " block ||= self.class.get_response_proc(#{ctx[:method_name].inspect})\n"
|
|
395
|
+
code += " __expected_response_codes__ = #{ctx[:expected_response_codes].inspect}\n"
|
|
396
|
+
code += " #{ctx[:method_name]}_raw_response(#{raw_call_args})\n"
|
|
330
397
|
code += " expected_response_code!(@response, __expected_response_codes__, __options__)\n"
|
|
331
|
-
|
|
332
|
-
if options[:stream] || options[:return] == :response
|
|
333
|
-
code += " @response\n"
|
|
334
|
-
elsif options[:return] == :body
|
|
335
|
-
code += " @response.body\n"
|
|
336
|
-
else
|
|
337
|
-
code += " handle_response(@response, __options__, &block)\n"
|
|
338
|
-
end
|
|
339
|
-
|
|
398
|
+
code += generate_response_handling_code(ctx[:options])
|
|
340
399
|
code += " end\n"
|
|
341
|
-
code
|
|
342
|
-
code
|
|
400
|
+
code + "end\n"
|
|
343
401
|
end
|
|
344
402
|
|
|
345
403
|
def route(method_name, path, options = {}, &block)
|
|
346
404
|
add_response_proc(method_name, block) if block
|
|
347
405
|
|
|
348
|
-
|
|
406
|
+
class_eval generate_route_code(method_name, path, options), __FILE__, __LINE__
|
|
349
407
|
end
|
|
350
408
|
end
|
|
351
409
|
|
|
@@ -368,7 +426,7 @@ module ClientApiBuilder
|
|
|
368
426
|
end
|
|
369
427
|
|
|
370
428
|
self.class.default_headers.each(&add_header_proc)
|
|
371
|
-
options[:headers]
|
|
429
|
+
options[:headers]&.each(&add_header_proc)
|
|
372
430
|
|
|
373
431
|
headers
|
|
374
432
|
end
|
|
@@ -396,8 +454,8 @@ module ClientApiBuilder
|
|
|
396
454
|
end
|
|
397
455
|
|
|
398
456
|
self.class.default_query_params.each(&add_query_param_proc)
|
|
399
|
-
query
|
|
400
|
-
options[:query]
|
|
457
|
+
query&.each(&add_query_param_proc)
|
|
458
|
+
options[:query]&.each(&add_query_param_proc)
|
|
401
459
|
|
|
402
460
|
query_params.empty? ? nil : self.class.build_query(self, query_params)
|
|
403
461
|
end
|
|
@@ -412,20 +470,34 @@ module ClientApiBuilder
|
|
|
412
470
|
end
|
|
413
471
|
|
|
414
472
|
def build_uri(path, query, options)
|
|
415
|
-
|
|
473
|
+
# Properly join base_url and path to handle missing/extra slashes
|
|
474
|
+
base = base_url.to_s
|
|
475
|
+
base = base.chomp('/') if base.end_with?('/')
|
|
476
|
+
path = path.to_s
|
|
477
|
+
path = "/#{path}" unless path.start_with?('/')
|
|
478
|
+
|
|
479
|
+
uri = URI(base + path)
|
|
416
480
|
uri.query = build_query(query, options)
|
|
417
481
|
uri
|
|
418
482
|
end
|
|
419
483
|
|
|
420
|
-
def expected_response_code!(response, expected_response_codes,
|
|
421
|
-
return if expected_response_codes.empty? && response.
|
|
484
|
+
def expected_response_code!(response, expected_response_codes, _options)
|
|
485
|
+
return if expected_response_codes.empty? && response.is_a?(Net::HTTPSuccess)
|
|
422
486
|
return if expected_response_codes.include?(response.code)
|
|
423
487
|
|
|
424
488
|
raise(::ClientApiBuilder::UnexpectedResponse.new("unexpected response code #{response.code}", response))
|
|
425
489
|
end
|
|
426
490
|
|
|
427
|
-
def parse_response(response,
|
|
428
|
-
|
|
491
|
+
def parse_response(response, _options)
|
|
492
|
+
body = response.body
|
|
493
|
+
return nil if body.nil? || body.empty?
|
|
494
|
+
|
|
495
|
+
JSON.parse(body)
|
|
496
|
+
rescue JSON::ParserError => e
|
|
497
|
+
raise ::ClientApiBuilder::UnexpectedResponse.new(
|
|
498
|
+
"Invalid JSON in response: #{e.message}",
|
|
499
|
+
response
|
|
500
|
+
)
|
|
429
501
|
end
|
|
430
502
|
|
|
431
503
|
def handle_response(response, options, &block)
|
|
@@ -467,16 +539,18 @@ module ClientApiBuilder
|
|
|
467
539
|
begin
|
|
468
540
|
@request_attempts += 1
|
|
469
541
|
yield
|
|
470
|
-
rescue
|
|
542
|
+
rescue StandardError => e
|
|
543
|
+
# Use StandardError instead of Exception to allow SystemExit, Interrupt, etc. to propagate
|
|
471
544
|
log_request_exception(e)
|
|
472
545
|
raise(e) if @request_attempts >= max_attempts || !retry_request?(e, options)
|
|
546
|
+
|
|
473
547
|
sleep_time = get_retry_request_sleep_time(e, options)
|
|
474
|
-
sleep(sleep_time) if sleep_time
|
|
548
|
+
sleep(sleep_time) if sleep_time&.positive?
|
|
475
549
|
retry
|
|
476
550
|
end
|
|
477
551
|
end
|
|
478
552
|
|
|
479
|
-
def get_retry_request_sleep_time(
|
|
553
|
+
def get_retry_request_sleep_time(_e, options)
|
|
480
554
|
options[:sleep] || self.class.default_options[:sleep] || 0.05
|
|
481
555
|
end
|
|
482
556
|
|
|
@@ -484,28 +558,39 @@ module ClientApiBuilder
|
|
|
484
558
|
options[:retries] || self.class.default_options[:max_retries] || 1
|
|
485
559
|
end
|
|
486
560
|
|
|
487
|
-
def request_wrapper(options)
|
|
561
|
+
def request_wrapper(options, &block)
|
|
488
562
|
retry_request(options) do
|
|
489
|
-
instrument_request
|
|
490
|
-
yield
|
|
491
|
-
end
|
|
563
|
+
instrument_request(&block)
|
|
492
564
|
end
|
|
493
565
|
end
|
|
494
566
|
|
|
495
|
-
|
|
496
|
-
|
|
567
|
+
# Determines whether to retry on a given exception.
|
|
568
|
+
# Override this method to customize retry behavior.
|
|
569
|
+
# By default, only retries on network-related errors, not application errors.
|
|
570
|
+
def retry_request?(exception, _options)
|
|
571
|
+
case exception
|
|
572
|
+
when Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
|
|
573
|
+
Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, EOFError
|
|
574
|
+
true
|
|
575
|
+
else
|
|
576
|
+
false
|
|
577
|
+
end
|
|
497
578
|
end
|
|
498
579
|
|
|
499
580
|
def log_request_exception(exception)
|
|
500
|
-
::ClientApiBuilder.logger
|
|
581
|
+
::ClientApiBuilder.logger&.error(exception)
|
|
501
582
|
end
|
|
502
583
|
|
|
503
584
|
def request_log_message
|
|
585
|
+
return '' unless request_options
|
|
586
|
+
|
|
504
587
|
method = request_options[:method].to_s.upcase
|
|
505
588
|
uri = request_options[:uri]
|
|
589
|
+
return "#{method} [no URI]" unless uri
|
|
590
|
+
|
|
506
591
|
response_code = response ? response.code : 'UNKNOWN'
|
|
592
|
+
duration = total_request_time ? (total_request_time * 1000).to_i : 0
|
|
507
593
|
|
|
508
|
-
duration = (total_request_time * 1000).to_i
|
|
509
594
|
"#{method} #{uri.scheme}://#{uri.host}#{uri.path}[#{response_code}] took #{duration}ms"
|
|
510
595
|
end
|
|
511
596
|
end
|
|
@@ -8,26 +8,26 @@ module ClientApiBuilder
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
module ClassMethods
|
|
11
|
-
def section(name, nested_router_options={}, &
|
|
11
|
+
def section(name, nested_router_options = {}, &)
|
|
12
12
|
kls = InheritanceHelper::ClassBuilder::Utils.create_class(
|
|
13
13
|
self,
|
|
14
14
|
name,
|
|
15
15
|
::ClientApiBuilder::NestedRouter,
|
|
16
16
|
nil,
|
|
17
17
|
'NestedRouter',
|
|
18
|
-
&
|
|
18
|
+
&
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
-
code =
|
|
22
|
-
def self.#{name}_router
|
|
23
|
-
|
|
24
|
-
end
|
|
21
|
+
code = <<~CODE
|
|
22
|
+
def self.#{name}_router
|
|
23
|
+
#{kls.name}
|
|
24
|
+
end
|
|
25
25
|
|
|
26
|
-
def #{name}
|
|
27
|
-
|
|
28
|
-
end
|
|
29
|
-
CODE
|
|
30
|
-
|
|
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__
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
end
|
data/script/console
CHANGED
|
@@ -8,7 +8,7 @@ autoload :BasicAuthExampleClient, 'basic_auth_example_client'
|
|
|
8
8
|
autoload :IMDBDatesetsClient, 'imdb_datasets_client'
|
|
9
9
|
autoload :LoremIpsumClient, 'lorem_ipsum_client'
|
|
10
10
|
require 'logger'
|
|
11
|
-
LOG = Logger.new(
|
|
11
|
+
LOG = Logger.new($stdout)
|
|
12
12
|
ClientApiBuilder.logger = LOG
|
|
13
13
|
ClientApiBuilder::ActiveSupportLogSubscriber.new(LOG).subscribe!
|
|
14
14
|
require 'irb'
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: client-api-builder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 2026-01-31 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: inheritance-helper
|
|
@@ -24,17 +23,25 @@ dependencies:
|
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: 0.2.5
|
|
27
|
-
description:
|
|
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.
|
|
28
32
|
email: dougyouch@gmail.com
|
|
29
33
|
executables: []
|
|
30
34
|
extensions: []
|
|
31
35
|
extra_rdoc_files: []
|
|
32
36
|
files:
|
|
33
37
|
- ".cursor.json"
|
|
38
|
+
- ".github/workflows/ci.yml"
|
|
34
39
|
- ".gitignore"
|
|
40
|
+
- ".rubocop.yml"
|
|
35
41
|
- ".ruby-gemset"
|
|
36
42
|
- ".ruby-version"
|
|
37
43
|
- ARCHITECTURE.md
|
|
44
|
+
- CLAUDE.md
|
|
38
45
|
- Gemfile
|
|
39
46
|
- Gemfile.lock
|
|
40
47
|
- LICENSE
|
|
@@ -55,8 +62,12 @@ files:
|
|
|
55
62
|
homepage: https://github.com/dougyouch/client-api-builder
|
|
56
63
|
licenses:
|
|
57
64
|
- MIT
|
|
58
|
-
metadata:
|
|
59
|
-
|
|
65
|
+
metadata:
|
|
66
|
+
rubygems_mfa_required: 'true'
|
|
67
|
+
homepage_uri: https://github.com/dougyouch/client-api-builder
|
|
68
|
+
source_code_uri: https://github.com/dougyouch/client-api-builder
|
|
69
|
+
changelog_uri: https://github.com/dougyouch/client-api-builder/blob/master/CHANGELOG.md
|
|
70
|
+
bug_tracker_uri: https://github.com/dougyouch/client-api-builder/issues
|
|
60
71
|
rdoc_options: []
|
|
61
72
|
require_paths:
|
|
62
73
|
- lib
|
|
@@ -64,15 +75,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
64
75
|
requirements:
|
|
65
76
|
- - ">="
|
|
66
77
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0'
|
|
78
|
+
version: '3.0'
|
|
68
79
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
80
|
requirements:
|
|
70
81
|
- - ">="
|
|
71
82
|
- !ruby/object:Gem::Version
|
|
72
83
|
version: '0'
|
|
73
84
|
requirements: []
|
|
74
|
-
rubygems_version: 3.
|
|
75
|
-
signing_key:
|
|
85
|
+
rubygems_version: 3.6.2
|
|
76
86
|
specification_version: 4
|
|
77
|
-
summary:
|
|
87
|
+
summary: Build robust, secure API clients through declarative configuration
|
|
78
88
|
test_files: []
|