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.
@@ -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
- :post,
19
- :put,
20
- :patch
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].dup
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].dup
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].dup
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].dup
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
- hsh[k] = "__||#{$1}||__" if v =~ /\{([a-z0-9_]+)\}/i
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
- list[idx] = "__||#{$1}||__" if v =~ /\{([a-z0-9_]+)\}/i
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
- "#\{escape_path(#{var})\}"
248
+ "#\{escape_path(#{var})}"
216
249
  end
217
250
 
218
- @@namespaces = []
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
- @@namespaces
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 generate_route_code(method_name, path, options = {})
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!(/\{([a-z0-9_]+)\}/i) do |_|
237
- get_instance_method($1)
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!(/:([a-z0-9_]+)/i) do |_|
242
- path_arguments << $1
243
- "#\{escape_path(#{$1})\}"
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
- has_body_param = options[:body].nil? && requires_body?(http_method, options)
282
+ [path, path_arguments]
283
+ end
247
284
 
248
- query =
249
- if options[:query]
250
- query_arguments = get_arguments(options[:query])
251
- str = options[:query].inspect
252
- str.gsub!(/"__\|\|(.+?)\|\|__"/) { $1 }
253
- str
254
- else
255
- query_arguments = []
256
- 'nil'
257
- end
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
- body =
260
- if options[:body]
261
- has_body_param = false
262
- body_arguments = get_arguments(options[:body])
263
- str = options[:body].inspect
264
- str.gsub!(/"__\|\|(.+?)\|\|__"/) { $1 }
265
- str
266
- else
267
- body_arguments = []
268
- has_body_param ? 'body' : 'nil'
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
- query_arguments.map!(&:to_s)
272
- body_arguments.map!(&:to_s)
273
- named_arguments = path_arguments + query_arguments + body_arguments
274
- named_arguments.uniq!
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
- expected_response_codes =
277
- if options[:expected_response_codes]
278
- options[:expected_response_codes]
279
- elsif options[:expected_response_code]
280
- [options[:expected_response_code]]
281
- else
282
- []
283
- end
284
- expected_response_codes.map!(&:to_s)
285
-
286
- stream_param =
287
- case options[:stream]
288
- when true,
289
- :file
290
- :file
291
- when :io
292
- :io
293
- end
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
- method_args = named_arguments.map { |arg_name| "#{arg_name}:" }
296
- method_args += ['body:'] if has_body_param
297
- method_args += ["#{stream_param}:"] if stream_param
298
- method_args += ['**__options__', '&block']
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
- code = "def #{method_name}_raw_response(" + method_args.join(', ') + ")\n"
301
- code += " __path__ = \"#{path}\"\n"
302
- code += " __query__ = #{query}\n"
303
- code += " __body__ = #{body}\n"
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__, headers: __headers__, connection_options: __connection_options__}\n"
309
- code += " @request_options[:#{stream_param}] = #{stream_param}\n" if stream_param
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
- case options[:stream]
312
- when true,
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 += "def #{method_name}(" + method_args.join(', ') + ")\n"
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(" + method_args.map { |a| a =~ /:$/ ? "#{a} #{a.sub(':', '')}" : a }.join(', ') + ")\n"
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 += "end\n"
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
- self.class_eval generate_route_code(method_name, path, options), __FILE__, __LINE__
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] && options[:headers].each(&add_header_proc)
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 && query.each(&add_query_param_proc)
400
- options[:query] && options[:query].each(&add_query_param_proc)
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
- uri = URI(base_url + path)
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, options)
421
- return if expected_response_codes.empty? && response.kind_of?(Net::HTTPSuccess)
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, options)
428
- response.body && JSON.parse(response.body)
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 Exception => e
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 && sleep_time > 0
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(e, options)
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 do
490
- yield
491
- end
563
+ instrument_request(&block)
492
564
  end
493
565
  end
494
566
 
495
- def retry_request?(exception, options)
496
- true
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 && ::ClientApiBuilder.logger.error(exception)
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={}, &block)
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
- &block
18
+ &
19
19
  )
20
20
 
21
- code = <<CODE
22
- def self.#{name}_router
23
- #{kls.name}
24
- end
21
+ code = <<~CODE
22
+ def self.#{name}_router
23
+ #{kls.name}
24
+ end
25
25
 
26
- def #{name}
27
- @#{name} ||= self.class.#{name}_router.new(self.root_router, #{nested_router_options.inspect})
28
- end
29
- CODE
30
- self.class_eval code, __FILE__, __LINE__
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(STDOUT)
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.5.6
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: 2025-05-05 00:00:00.000000000 Z
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: Create API clients through configuration with complete transparency
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
- post_install_message:
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.3.3
75
- signing_key:
85
+ rubygems_version: 3.6.2
76
86
  specification_version: 4
77
- summary: Utility for creating API clients through configuration
87
+ summary: Build robust, secure API clients through declarative configuration
78
88
  test_files: []