Module: ClientApiBuilder::Router::ClassMethods

Defined in:
lib/client_api_builder/router.rb

Constant Summary collapse

REQUIRED_BODY_HTTP_METHODS =
%i[
  post
  put
  patch
].freeze
ALLOWED_URL_SCHEMES =

Allowed URL schemes for base_url to prevent SSRF attacks

%w[http https].freeze
NAMESPACE_THREAD_KEY =

Thread-local storage key for namespaces to ensure thread safety

:client_api_builder_namespaces

Instance Method Summary collapse

Instance Method Details

#add_response_proc(method_name, proc) ⇒ Object

tracks the proc used to handle responses



53
54
55
56
57
# File 'lib/client_api_builder/router.rb', line 53

def add_response_proc(method_name, proc)
  response_procs = deep_dup_hash(default_options[:response_procs])
  response_procs[method_name] = proc
  add_value_to_class_method(:default_options, response_procs: response_procs)
end

#auto_detect_http_method(method_name) ⇒ Object



171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/client_api_builder/router.rb', line 171

def auto_detect_http_method(method_name)
  case method_name.to_s
  when /^(?:post|create|add|insert)/i
    :post
  when /^(?:put|update|modify|change)/i
    :put
  when /^(?:patch)/i
    :patch
  when /^(?:delete|remove)/i
    :delete
  else
    :get
  end
end

#base_url(url = nil) ⇒ Object

set/get base url Validates URL scheme to prevent SSRF attacks



66
67
68
69
70
71
# File 'lib/client_api_builder/router.rb', line 66

def base_url(url = nil)
  return default_options[:base_url] unless url

  validate_base_url!(url)
  add_value_to_class_method(:default_options, base_url: url)
end

#body_builder(builder = nil, &block) ⇒ Object

set the builder to :to_json, :to_query, :query_params or specify a proc to handle building the request body payload or get the body builder



85
86
87
88
89
# File 'lib/client_api_builder/router.rb', line 85

def body_builder(builder = nil, &block)
  return default_options[:body_builder] if builder.nil? && block.nil?

  add_value_to_class_method(:default_options, body_builder: builder || block)
end

#build_body(router, body) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/client_api_builder/router.rb', line 143

def build_body(router, body)
  case body_builder
  when :to_json
    body.to_json
  when :to_query
    body.to_query
  when :query_params
    ClientApiBuilder::QueryParams.new.to_query(body)
  when Symbol
    router.send(body_builder, body)
  else
    router.instance_exec(body, &body_builder)
  end
end

#build_body_code(options, has_body_param) ⇒ Object



323
324
325
326
327
328
329
330
331
332
# File 'lib/client_api_builder/router.rb', line 323

def build_body_code(options, has_body_param)
  if options[:body]
    body_arguments = get_arguments(options[:body])
    str = value_to_code(options[:body])
    str = str.gsub(/"__\|\|(.+?)\|\|__"/) { Regexp.last_match(1) }
    [str, body_arguments.map(&:to_s), false]
  else
    [has_body_param ? 'body' : 'nil', [], has_body_param]
  end
end

#build_method_args(named_arguments, has_body_param, stream_param) ⇒ Object



346
347
348
349
350
351
# File 'lib/client_api_builder/router.rb', line 346

def build_method_args(named_arguments, has_body_param, stream_param)
  args = named_arguments.map { |arg_name| "#{arg_name}:" }
  args += ['body:'] if has_body_param
  args += ["#{stream_param}:"] if stream_param
  args + ['**__options__', '&block']
end

#build_query(router, query) ⇒ Object



158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/client_api_builder/router.rb', line 158

def build_query(router, query)
  case query_builder
  when :to_query
    query.to_query
  when :query_params
    ClientApiBuilder::QueryParams.new.to_query(query)
  when Symbol
    router.send(query_builder, query)
  else
    router.instance_exec(query, &query_builder)
  end
end

#build_query_code(options) ⇒ Object



312
313
314
315
316
317
318
319
320
321
# File 'lib/client_api_builder/router.rb', line 312

def build_query_code(options)
  if options[:query]
    query_arguments = get_arguments(options[:query])
    str = value_to_code(options[:query])
    str = str.gsub(/"__\|\|(.+?)\|\|__"/) { Regexp.last_match(1) }
    [str, query_arguments.map(&:to_s)]
  else
    ['nil', []]
  end
end

#configure_retries(max_retries, sleep_time_between_retries_in_seconds = 0.05) ⇒ Object



113
114
115
116
117
118
119
# File 'lib/client_api_builder/router.rb', line 113

def configure_retries(max_retries, sleep_time_between_retries_in_seconds = 0.05)
  add_value_to_class_method(
    :default_options,
    max_retries: max_retries,
    sleep: sleep_time_between_retries_in_seconds
  )
end

#connection_option(name, value) ⇒ Object

set a connection_option, specific to Net::HTTP



107
108
109
110
111
# File 'lib/client_api_builder/router.rb', line 107

def connection_option(name, value)
  connection_options = deep_dup_hash(default_options[:connection_options])
  connection_options[name] = value
  add_value_to_class_method(:default_options, connection_options: connection_options)
end

#deep_dup_hash(hash) ⇒ Object

Deep duplicates a hash to prevent shared mutable state



28
29
30
31
32
33
34
35
36
# File 'lib/client_api_builder/router.rb', line 28

def deep_dup_hash(hash)
  hash.transform_values do |value|
    case value
    when Hash then deep_dup_hash(value)
    when Array then value.map { |v| v.is_a?(Hash) ? deep_dup_hash(v) : v }
    else value
    end
  end
end

#default_connection_optionsObject

get configured connection_options



134
135
136
# File 'lib/client_api_builder/router.rb', line 134

def default_connection_options
  default_options[:connection_options]
end

#default_headersObject

get default headers



129
130
131
# File 'lib/client_api_builder/router.rb', line 129

def default_headers
  default_options[:headers]
end

#default_optionsObject



38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/client_api_builder/router.rb', line 38

def default_options
  {
    base_url: nil,
    body_builder: :to_json,
    connection_options: {},
    headers: {},
    query_builder: Hash.method_defined?(:to_query) ? :to_query : :query_params,
    query_params: {},
    response_procs: {},
    max_retries: 1,
    sleep: 0.05
  }.freeze
end

#default_query_paramsObject

get default query_params to add to all requests



139
140
141
# File 'lib/client_api_builder/router.rb', line 139

def default_query_params
  default_options[:query_params]
end

#determine_stream_param(options) ⇒ Object



339
340
341
342
343
344
# File 'lib/client_api_builder/router.rb', line 339

def determine_stream_param(options)
  case options[:stream]
  when true, :file then :file
  when :io then :io
  end
end

#extract_expected_response_codes(options) ⇒ Object



334
335
336
337
# File 'lib/client_api_builder/router.rb', line 334

def extract_expected_response_codes(options)
  codes = options[:expected_response_codes] || (options[:expected_response_code] ? [options[:expected_response_code]] : [])
  codes.map(&:to_s)
end

#generate_raw_response_method(ctx) ⇒ Object



401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'lib/client_api_builder/router.rb', line 401

def generate_raw_response_method(ctx)
  code = "def #{ctx[:method_name]}_raw_response(#{ctx[:method_args].join(', ')})\n"
  code += "  __path__ = \"#{ctx[:path]}\"\n"
  code += "  __query__ = #{ctx[:query]}\n"
  code += "  __body__ = #{ctx[:body]}\n"
  code += "  __uri__ = build_uri(__path__, __query__, __options__)\n"
  code += "  __body__ = build_body(__body__, __options__)\n"
  code += "  __headers__ = build_headers(__options__)\n"
  code += "  __connection_options__ = build_connection_options(__options__)\n"
  code += "  @request_options = {method: #{ctx[:http_method].inspect}, uri: __uri__, body: __body__, " \
          "headers: __headers__, connection_options: __connection_options__}\n"
  code += generate_request_call_code(ctx[:options], ctx[:stream_param])
  code + "end\n\n"
end

#generate_request_call_code(options, stream_param) ⇒ Object



353
354
355
356
357
358
359
360
361
362
363
# File 'lib/client_api_builder/router.rb', line 353

def generate_request_call_code(options, stream_param)
  code = "  @request_options[:#{stream_param}] = #{stream_param}\n" if stream_param
  code ||= ''

  code + case options[:stream]
         when true, :file then "  @response = stream_to_file(**@request_options)\n"
         when :io then "  @response = stream_to_io(**@request_options)\n"
         when :block then "  @response = stream(**@request_options, &block)\n"
         else "  @response = request(**@request_options)\n"
         end
end

#generate_response_handling_code(options) ⇒ Object



365
366
367
368
369
370
371
372
373
# File 'lib/client_api_builder/router.rb', line 365

def generate_response_handling_code(options)
  if options[:stream] || options[:return] == :response
    "    @response\n"
  elsif options[:return] == :body
    "    @response.body\n"
  else
    "    handle_response(@response, __options__, &block)\n"
  end
end

#generate_route_code(method_name, path, options = {}) ⇒ Object

Raises:

  • (ArgumentError)


375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/client_api_builder/router.rb', line 375

def generate_route_code(method_name, path, options = {})
  # Validate method_name to prevent code injection
  raise ArgumentError, "Invalid method name: #{method_name.inspect}" unless method_name.to_s.match?(/\A[a-z_][a-z0-9_]*\z/i)

  http_method = options[:method] || auto_detect_http_method(method_name)
  path, path_arguments = process_route_path(path)
  has_body_param = options[:body].nil? && requires_body?(http_method, options)

  query, query_arguments = build_query_code(options)
  body, body_arguments, has_body_param = build_body_code(options, has_body_param)

  named_arguments = (path_arguments + query_arguments + body_arguments).uniq
  expected_response_codes = extract_expected_response_codes(options)
  stream_param = determine_stream_param(options)
  method_args = build_method_args(named_arguments, has_body_param, stream_param)

  route_context = {
    method_name: method_name, method_args: method_args, path: path,
    query: query, body: body, http_method: http_method,
    options: options, stream_param: stream_param,
    expected_response_codes: expected_response_codes
  }

  generate_raw_response_method(route_context) + generate_wrapper_method(route_context)
end

#generate_wrapper_method(ctx) ⇒ Object



416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/client_api_builder/router.rb', line 416

def generate_wrapper_method(ctx)
  raw_call_args = ctx[:method_args].map { |a| a =~ /:$/ ? "#{a} #{a.sub(':', '')}" : a }.join(', ')

  code = "def #{ctx[:method_name]}(#{ctx[:method_args].join(', ')})\n"
  code += "  request_wrapper(__options__) do\n"
  code += "    block ||= self.class.get_response_proc(#{ctx[:method_name].inspect})\n"
  code += "    __expected_response_codes__ = #{ctx[:expected_response_codes].inspect}\n"
  code += "    #{ctx[:method_name]}_raw_response(#{raw_call_args})\n"
  code += "    expected_response_code!(@response, __expected_response_codes__, __options__)\n"
  code += generate_response_handling_code(ctx[:options])
  code += "  end\n"
  code + "end\n"
end

#get_arguments(value) ⇒ Object

returns a list of arguments to add to the route method



236
237
238
239
240
241
242
243
244
245
# File 'lib/client_api_builder/router.rb', line 236

def get_arguments(value)
  case value
  when Hash
    get_hash_arguments(value)
  when Array
    get_array_arguments(value)
  else
    []
  end
end

#get_array_arguments(list) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/client_api_builder/router.rb', line 214

def get_array_arguments(list)
  arguments = []
  list.each_with_index do |v, idx|
    case v
    when Symbol
      list[idx] = "__||#{v}||__"
      arguments << v
    when Hash
      arguments += get_hash_arguments(v)
    when Array
      arguments += get_array_arguments(v)
    when String
      # Use match with block form to avoid thread-unsafe $1 global variable
      if (match = v.match(/\{([a-z0-9_]+)\}/i))
        list[idx] = "__||#{match[1]}||__"
      end
    end
  end
  arguments
end

#get_hash_arguments(hsh) ⇒ Object



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/client_api_builder/router.rb', line 193

def get_hash_arguments(hsh)
  arguments = []
  hsh.each do |k, v|
    case v
    when Symbol
      hsh[k] = "__||#{v}||__"
      arguments << v
    when Hash
      arguments += get_hash_arguments(v)
    when Array
      arguments += get_array_arguments(v)
    when String
      # Use match with block form to avoid thread-unsafe $1 global variable
      if (match = v.match(/\{([a-z0-9_]+)\}/i))
        hsh[k] = "__||#{match[1]}||__"
      end
    end
  end
  arguments
end

#get_instance_method(var) ⇒ Object



247
248
249
# File 'lib/client_api_builder/router.rb', line 247

def get_instance_method(var)
  "#\{escape_path(#{var})}"
end

#get_response_proc(method_name) ⇒ Object

retrieves the proc used to handle the response



60
61
62
# File 'lib/client_api_builder/router.rb', line 60

def get_response_proc(method_name)
  default_options[:response_procs][method_name]
end

#header(name, value = nil, &block) ⇒ Object

add a request header



100
101
102
103
104
# File 'lib/client_api_builder/router.rb', line 100

def header(name, value = nil, &block)
  headers = deep_dup_hash(default_options[:headers])
  headers[name] = value || block
  add_value_to_class_method(:default_options, headers: headers)
end

#namespace(name) ⇒ Object

a namespace is a top level path to apply to all routes within the namespace block



259
260
261
262
263
264
265
# File 'lib/client_api_builder/router.rb', line 259

def namespace(name)
  namespaces << name
  yield
ensure
  # Always pop the namespace, even if an exception occurs during yield
  namespaces.pop
end

#namespacesObject



254
255
256
# File 'lib/client_api_builder/router.rb', line 254

def namespaces
  Thread.current[NAMESPACE_THREAD_KEY] ||= []
end

#process_route_path(path) ⇒ Object



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/client_api_builder/router.rb', line 267

def process_route_path(path)
  path = namespaces.join + path

  # instance method - use block parameter to avoid thread-unsafe $1
  path = path.gsub(/\{([a-z0-9_]+)\}/i) do |_match|
    get_instance_method(Regexp.last_match(1))
  end

  path_arguments = []
  path = path.gsub(/:([a-z0-9_]+)/i) do |_match|
    param_name = Regexp.last_match(1)
    path_arguments << param_name
    "#\{escape_path(#{param_name})}"
  end

  [path, path_arguments]
end

#query_builder(builder = nil, &block) ⇒ Object

set the builder to :to_query, :query_params or specify a proc to handle building the request query params or get the query builder



93
94
95
96
97
# File 'lib/client_api_builder/router.rb', line 93

def query_builder(builder = nil, &block)
  return default_options[:query_builder] if builder.nil? && block.nil?

  add_value_to_class_method(:default_options, query_builder: builder || block)
end

#query_param(name, value = nil, &block) ⇒ Object

add a query param to all requests



122
123
124
125
126
# File 'lib/client_api_builder/router.rb', line 122

def query_param(name, value = nil, &block)
  query_params = deep_dup_hash(default_options[:query_params])
  query_params[name] = value || block
  add_value_to_class_method(:default_options, query_params: query_params)
end

#requires_body?(http_method, options) ⇒ Boolean

Returns:

  • (Boolean)


186
187
188
189
190
191
# File 'lib/client_api_builder/router.rb', line 186

def requires_body?(http_method, options)
  return !options[:no_body] if options.key?(:no_body)
  return options[:has_body] if options.key?(:has_body)

  REQUIRED_BODY_HTTP_METHODS.include?(http_method)
end

#route(method_name, path, options = {}, &block) ⇒ Object



430
431
432
433
434
# File 'lib/client_api_builder/router.rb', line 430

def route(method_name, path, options = {}, &block)
  add_response_proc(method_name, block) if block

  class_eval generate_route_code(method_name, path, options), __FILE__, __LINE__
end

#validate_base_url!(url) ⇒ Object

Validates that base_url uses an allowed scheme



74
75
76
77
78
79
80
81
# File 'lib/client_api_builder/router.rb', line 74

def validate_base_url!(url)
  uri = URI.parse(url.to_s)
  return if ALLOWED_URL_SCHEMES.include?(uri.scheme&.downcase)

  raise ArgumentError, "Invalid base_url scheme: #{uri.scheme.inspect}. Allowed: #{ALLOWED_URL_SCHEMES.join(', ')}"
rescue URI::InvalidURIError => e
  raise ArgumentError, "Invalid base_url: #{e.message}"
end

#value_to_code(value) ⇒ Object

Converts a value to Ruby code string with consistent hash syntax across Ruby versions. Uses modern value syntax for symbol keys.



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/client_api_builder/router.rb', line 287

def value_to_code(value)
  case value
  when Hash
    return '{}' if value.empty?

    pairs = value.map do |k, v|
      key_code = case k
                 when Symbol then "#{k}: "
                 when String then "#{k.inspect} => "
                 else "#{value_to_code(k)} => "
                 end
      "#{key_code}#{value_to_code(v)}"
    end
    "{#{pairs.join(', ')}}"
  when Array
    "[#{value.map { |v| value_to_code(v) }.join(', ')}]"
  when NilClass
    'nil'
  when TrueClass, FalseClass
    value.to_s
  else
    value.inspect
  end
end