contractful 0.1.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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +142 -0
  3. data/ARCHITECTURE.md +134 -0
  4. data/AUTHORS +106 -0
  5. data/CODE_OF_CONDUCT.md +128 -0
  6. data/LICENSE +21 -0
  7. data/README.md +1290 -0
  8. data/SECURITY.md +67 -0
  9. data/bin/contractful +128 -0
  10. data/history.md +801 -0
  11. data/lib/contractful/abstract_response.rb +301 -0
  12. data/lib/contractful/circuit_breaker.rb +221 -0
  13. data/lib/contractful/connection_pool.rb +166 -0
  14. data/lib/contractful/contract/auth.rb +257 -0
  15. data/lib/contractful/contract/config.rb +93 -0
  16. data/lib/contractful/contract/definition.rb +295 -0
  17. data/lib/contractful/contract/dsl.rb +501 -0
  18. data/lib/contractful/contract/executor.rb +492 -0
  19. data/lib/contractful/contract/openapi_import.rb +1135 -0
  20. data/lib/contractful/contract/pagination.rb +218 -0
  21. data/lib/contractful/contract/param.rb +505 -0
  22. data/lib/contractful/contract/postman_import/auth.rb +39 -0
  23. data/lib/contractful/contract/postman_import/body.rb +87 -0
  24. data/lib/contractful/contract/postman_import/operation.rb +107 -0
  25. data/lib/contractful/contract/postman_import/scope.rb +37 -0
  26. data/lib/contractful/contract/postman_import/url.rb +80 -0
  27. data/lib/contractful/contract/postman_import.rb +164 -0
  28. data/lib/contractful/contract/response_field.rb +167 -0
  29. data/lib/contractful/contract/sequence.rb +432 -0
  30. data/lib/contractful/contract/swagger2/components.rb +84 -0
  31. data/lib/contractful/contract/swagger2/operations.rb +81 -0
  32. data/lib/contractful/contract/swagger2/parameters.rb +93 -0
  33. data/lib/contractful/contract/swagger2/references.rb +55 -0
  34. data/lib/contractful/contract/swagger2.rb +55 -0
  35. data/lib/contractful/contract.rb +85 -0
  36. data/lib/contractful/exceptions.rb +328 -0
  37. data/lib/contractful/notifications/active_support.rb +40 -0
  38. data/lib/contractful/notifications.rb +138 -0
  39. data/lib/contractful/pacer.rb +161 -0
  40. data/lib/contractful/params_array.rb +85 -0
  41. data/lib/contractful/payload.rb +340 -0
  42. data/lib/contractful/platform.rb +52 -0
  43. data/lib/contractful/rate_limit.rb +133 -0
  44. data/lib/contractful/raw_response.rb +70 -0
  45. data/lib/contractful/request.rb +1483 -0
  46. data/lib/contractful/resource.rb +189 -0
  47. data/lib/contractful/response.rb +109 -0
  48. data/lib/contractful/utils.rb +202 -0
  49. data/lib/contractful/version.rb +11 -0
  50. data/lib/contractful/windows/root_certs.rb +107 -0
  51. data/lib/contractful/windows.rb +9 -0
  52. data/lib/contractful.rb +233 -0
  53. metadata +244 -0
@@ -0,0 +1,1135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'timeout'
6
+ require_relative '../contract'
7
+ require_relative 'swagger2'
8
+
9
+ # rubocop:disable Naming/MethodParameterName
10
+ # The rendering helpers below follow OpenAPI tooling conventions for the
11
+ # local variables they pass around: `op` for an Operation object, `p` for
12
+ # a parameter, `f` for a response field and `d` for a Definition. The
13
+ # single letters are domain-idiomatic here; long-form names would be
14
+ # repeated dozens of times across these small, well-scoped helpers.
15
+ module Contractful
16
+ module Contract
17
+ # OpenAPI 3.x import helper (REQ-N01) — a development-time, opt-in reader
18
+ # that converts an OpenAPI 3.x document into `contract` declarations.
19
+ #
20
+ # This is a source-to-source tool: it is not part of the runtime request
21
+ # path and adds no dependencies (YAML/JSON parsing is stdlib). Require it
22
+ # explicitly — it never loads as a side effect of `contractful`:
23
+ #
24
+ # require 'contractful/contract/openapi_import'
25
+ #
26
+ # Three entry points:
27
+ #
28
+ # * {OpenAPIImport.definitions} — build {Definition} objects from a
29
+ # document (accepts a Hash, a file path, or YAML/JSON text).
30
+ # * {OpenAPIImport.to_ruby} — render a ready-to-paste client class,
31
+ # generated from those same definitions.
32
+ # * {OpenAPIImport.import} — the full result: definitions, base URL,
33
+ # auth hints and the notes channel.
34
+ #
35
+ # The mapping is deliberately conservative and HONEST about what it
36
+ # cannot express. Every decision that loses information (header params,
37
+ # multipart bodies, oneOf unions, `default` responses, ...) is reported
38
+ # on {Import#notes} so generated code can be reviewed against the source
39
+ # document. Nothing silently disappears.
40
+ #
41
+ # Mapped: path/query/body-object params (type + required + default +
42
+ # enum/min/max/length/pattern constraints), `payload_style` (form for
43
+ # urlencoded bodies, query for body-less POST/PUT/PATCH), response
44
+ # statuses (numeric and `2XX` wildcards), JSON response types and
45
+ # open-schema `response_field` bindings, base URL from `servers` and
46
+ # auth *hints* from `securitySchemes`.
47
+ module OpenAPIImport
48
+ # Raised when a document cannot be parsed or is not OpenAPI 3.x, and
49
+ # for rendering failures in {Import#to_ruby}.
50
+ class Error < Contractful::Contract::Error; end
51
+
52
+ HTTP_METHODS = %w[get put post patch delete head options trace].freeze
53
+ BODY_VERBS = %i[post put patch].freeze
54
+
55
+ MAX_REF_DEPTH = 10
56
+
57
+ # Cap for #compile_pattern_with_timeout. 1 second is generous for
58
+ # any reasonable OpenAPI pattern; catastrophic-backtracking patterns
59
+ # blow past it inside a few hundred ms.
60
+ PATTERN_COMPILE_TIMEOUT_SECONDS = 1
61
+
62
+ # Where bundled external files live inside the document.
63
+ EXTERNAL_KEY = 'x-contractful-external'
64
+ # A URL scheme makes a $ref remote (scheme://...).
65
+ REMOTE_REF = %r{\A[a-zA-Z][a-zA-Z0-9+.-]*://}
66
+
67
+ module_function
68
+
69
+ # --- public API -------------------------------------------------------
70
+
71
+ # Parse an OpenAPI document. +source+ may be a Hash (already parsed),
72
+ # a String/Pathname file path, or a YAML/JSON text.
73
+ #
74
+ # @return [Hash] the document
75
+ # @raise [Error]
76
+ def load(source)
77
+ case source
78
+ when Hash
79
+ source
80
+ when Pathname
81
+ load_file(source.to_s)
82
+ when String
83
+ if source.include?("\n") || source.lstrip.start_with?('{', '[')
84
+ parse_text(source)
85
+ else
86
+ load_file(source)
87
+ end
88
+ else
89
+ raise Error,
90
+ "cannot load an OpenAPI document from #{source.inspect} " \
91
+ '(pass a Hash, a file path or YAML/JSON text)'
92
+ end
93
+ end
94
+
95
+ # Convert a document into contract definitions.
96
+ #
97
+ # @param source Hash, file path or YAML/JSON text
98
+ # @param only [Array<Symbol>] keep only these operation names
99
+ # @param except [Array<Symbol>] drop these operation names
100
+ # @return [Hash{Symbol => Definition}] operation name => definition
101
+ def definitions(source, only: nil, except: nil)
102
+ import(source, only: only, except: except).definitions
103
+ end
104
+
105
+ # Full import: definitions plus the auxiliary material a generated
106
+ # client needs.
107
+ #
108
+ # @return [Import]
109
+ def import(source, only: nil, except: nil)
110
+ doc = load(source)
111
+ doc = Swagger2.convert(doc) if Swagger2.swagger?(doc)
112
+ validate_document!(doc)
113
+
114
+ ctx = Context.new(doc)
115
+ base_url = resolve_base_url(doc, ctx)
116
+ auth_hints = derive_auth_hints(doc, ctx)
117
+
118
+ definitions = {}
119
+ Array(doc['paths']).each do |path, item|
120
+ item = deref(ctx, item, "path #{path}")
121
+ next unless item.is_a?(Hash)
122
+
123
+ shared = Array(item['parameters'])
124
+ note_servers(item, "path #{path}", ctx)
125
+
126
+ item.each do |method, op|
127
+ next unless HTTP_METHODS.include?(method.to_s.downcase)
128
+
129
+ if method.to_s.downcase == 'trace'
130
+ ctx.note("#{path}: trace operation skipped (unsupported verb)")
131
+ next
132
+ end
133
+ next unless op.is_a?(Hash)
134
+
135
+ name = ctx.unique_name(derive_name(method, path, op))
136
+ next if skip_name?(name, only, except)
137
+
138
+ definitions[name] =
139
+ build_definition(ctx, name, method.to_s.downcase.to_sym, path, op, shared)
140
+ end
141
+ end
142
+
143
+ ctx.note('webhooks are not imported') if doc.key?('webhooks')
144
+
145
+ Import.new(document: doc, base_url: base_url, auth_hints: auth_hints,
146
+ definitions: definitions, notes: ctx.notes)
147
+ end
148
+
149
+ # Render a ready-to-paste client class from an OpenAPI document. The
150
+ # source is generated from the imported {Definition} objects, so it is
151
+ # guaranteed to round-trip: evaluating the output yields a class whose
152
+ # contracts describe exactly like the imported definitions.
153
+ #
154
+ # @param class_name [String] constant, may be namespaced (A::B)
155
+ # @return [String] Ruby source
156
+ def to_ruby(source, class_name: 'GeneratedClient', only: nil, except: nil)
157
+ import(source, only: only, except: except).to_ruby(class_name: class_name)
158
+ end
159
+
160
+ # --- result object ------------------------------------------------------
161
+
162
+ # The result of {OpenAPIImport.import}.
163
+ class Import
164
+ # The parsed OpenAPI document.
165
+ attr_reader :document
166
+ # Base URL derived from `servers` (nil when not derivable).
167
+ attr_reader :base_url
168
+ # Suggested `auth ...` declarations from `securitySchemes`
169
+ # (commented-out hints — credentials are never invented).
170
+ attr_reader :auth_hints
171
+ # {Symbol => Definition} — the converted operations.
172
+ attr_reader :definitions
173
+ # Every information-losing mapping decision, for review.
174
+ attr_reader :notes
175
+
176
+ def initialize(document:, base_url:, auth_hints:, definitions:, notes:)
177
+ @document = document
178
+ @base_url = base_url
179
+ @auth_hints = auth_hints
180
+ @definitions = definitions
181
+ @notes = notes.freeze
182
+ end
183
+
184
+ # Render a client class source from this import.
185
+ def to_ruby(class_name: 'GeneratedClient')
186
+ Renderer.render(self, class_name: class_name)
187
+ end
188
+ end
189
+
190
+ # --- source renderer ----------------------------------------------------
191
+
192
+ # Renders {Import} to client-class source. The renderer works from the
193
+ # built {Definition} objects (never from the raw document), which is
194
+ # what makes the output round-trip through Definition#describe.
195
+ module Renderer
196
+ RESPONSE_FIELD_TYPE_SOURCES = {
197
+ String => ':string', Integer => ':integer', Float => ':float',
198
+ Array => ':array', Hash => ':hash'
199
+ }.freeze
200
+
201
+ module_function
202
+
203
+ def render(import, class_name:)
204
+ parts = class_name.to_s.split('::')
205
+ if parts.empty? || !parts.all? { |p| p.match?(/\A[A-Z][A-Za-z0-9_]*\z/) }
206
+ raise Error, "class_name must be a Ruby constant, got #{class_name.inspect}"
207
+ end
208
+
209
+ out = []
210
+ out << '# frozen_string_literal: true'
211
+ out << '# Generated by Contractful::Contract::OpenAPIImport — review before use.'
212
+ info = import.document['info'].is_a?(Hash) ? import.document['info'] : {}
213
+ if info['title'] || info['version']
214
+ origin = import.document['x-contractful-source'] || "OpenAPI #{import.document['openapi']}"
215
+ version = info['version'] ? ", version #{info['version']}" : ''
216
+ out << "# Source: #{info['title']} (#{origin}#{version})"
217
+ end
218
+ unless import.notes.empty?
219
+ out << '#'
220
+ out << '# Import notes (review these):'
221
+ import.notes.each { |note| out << "# - #{note}" }
222
+ end
223
+ out << ''
224
+ out << "module #{parts.shift}" while parts.size > 1
225
+ out << "class #{parts.first}"
226
+ out << ' include Contractful::Contract'
227
+ out << ''
228
+ out << " base #{import.base_url.inspect}" if import.base_url
229
+ import.auth_hints.each { |hint| out << " # #{hint}" }
230
+ out << '' if import.base_url || import.auth_hints.any?
231
+
232
+ import.definitions.each_value do |definition|
233
+ out.concat(render_definition(definition))
234
+ out << ''
235
+ end
236
+ out << 'end'
237
+ out.select { |l| l.start_with?('module ') }.size.times { out << 'end' }
238
+ out.join("\n") << "\n"
239
+ end
240
+
241
+ def render_definition(d)
242
+ lines = []
243
+ header = " contract #{d.name.inspect}, #{request_line(d).inspect}"
244
+ header << ", description: #{d.description.inspect}" if d.description
245
+ header << ' do'
246
+ lines << header
247
+
248
+ d.params.each_value do |param|
249
+ lines << " param #{param_args(param)}"
250
+ end
251
+ lines << " payload_style #{d.payload_style.inspect}" if d.payload_style
252
+ lines << ' strict_params false' if d.strict == false
253
+
254
+ if d.response_statuses
255
+ rendered = d.response_statuses.map { |s| render_status(s) }
256
+ line = " responds #{rendered.join(', ')}"
257
+ line << ", type: #{respond_type_source(d.response_type)}" if d.response_type
258
+ lines << line
259
+ end
260
+
261
+ d.response_fields.each_value do |field|
262
+ lines << " response_field #{field_args(field)}"
263
+ end
264
+
265
+ lines << ' end'
266
+ lines
267
+ end
268
+
269
+ def request_line(d)
270
+ "#{d.verb.to_s.upcase} #{d.path_template}"
271
+ end
272
+
273
+ def param_args(p)
274
+ args = [p.name.inspect]
275
+ type = type_source(p.type)
276
+ args << type if type
277
+ args << 'required: true' if p.required?
278
+
279
+ if p.has_default?
280
+ if p.lazy_default?
281
+ raise Error,
282
+ "cannot render a Proc default for param :#{p.name} — " \
283
+ 'write it by hand'
284
+ end
285
+ value = p.default_value
286
+ args << "default: #{value.inspect}"
287
+ end
288
+ args << "header: #{p.header.inspect}" if p.header
289
+ args << "cookie: #{p.cookie.inspect}" if p.cookie
290
+ args << "inclusion: #{wrap_range(p.inclusion)}" if p.inclusion
291
+ args << "min: #{wrap_range(p.min)}" if p.min
292
+ args << "max: #{wrap_range(p.max)}" if p.max
293
+ args << "length: #{wrap_range(p.length)}" if p.length
294
+ args << "pattern: #{p.pattern.inspect}" if p.pattern
295
+ args.join(', ')
296
+ end
297
+
298
+ def field_args(f)
299
+ args = [f.name.inspect]
300
+ type = type_source_for_field(f.type)
301
+ args << type if type
302
+ args << 'required: true' if f.required?
303
+ args << "inclusion: #{wrap_range(f.inclusion)}" if f.inclusion
304
+ args << 'strict: true' if f.strict?
305
+ args.join(', ')
306
+ end
307
+
308
+ def type_source(type)
309
+ return nil if type.nil?
310
+ return ':boolean' if type.equal?(:boolean)
311
+ return type.name if type.is_a?(Class)
312
+
313
+ raise Error, 'cannot render a Proc param type — write it by hand'
314
+ end
315
+
316
+ def type_source_for_field(type)
317
+ return nil if type.nil?
318
+ return ":#{type}" if %i[boolean number].include?(type)
319
+ return RESPONSE_FIELD_TYPE_SOURCES[type] if RESPONSE_FIELD_TYPE_SOURCES.key?(type)
320
+
321
+ type.name
322
+ end
323
+
324
+ def respond_type_source(type)
325
+ return ':json' if type.equal?(:json)
326
+ return ':string' if type.equal?(String)
327
+ return ':array' if type.equal?(Array)
328
+ return ':hash' if type.equal?(Hash)
329
+
330
+ type.name
331
+ end
332
+
333
+ def render_status(status)
334
+ status.is_a?(Range) ? "(#{status.min}..#{status.max})" : status.to_s
335
+ end
336
+
337
+ def wrap_range(value)
338
+ value.is_a?(Range) ? "(#{value.min}..#{value.max})" : value.inspect
339
+ end
340
+ end
341
+
342
+ # --- internals ----------------------------------------------------------
343
+
344
+ # Mutable import state: the document, the notes channel and taken
345
+ # operation names.
346
+ class Context
347
+ attr_reader :document, :notes
348
+
349
+ def initialize(document)
350
+ @document = document
351
+ @notes = []
352
+ @taken = {}
353
+ end
354
+
355
+ def note(message)
356
+ @notes << message unless @notes.include?(message)
357
+ end
358
+
359
+ def unique_name(base)
360
+ base = base.to_s
361
+ candidate = base
362
+ n = 1
363
+ while @taken.key?(candidate) || Contract::RESERVED_NAMES.include?(candidate.to_sym)
364
+ n += 1
365
+ candidate = "#{base}_#{n}"
366
+ end
367
+ @taken[candidate] = true
368
+ candidate.to_sym
369
+ end
370
+ end
371
+
372
+ def validate_document!(doc)
373
+ raise Error, "not an OpenAPI document: #{doc.class} (expected Hash)" unless doc.is_a?(Hash)
374
+
375
+ version = doc['openapi'].to_s
376
+ unless version.start_with?('3')
377
+ raise Error,
378
+ "missing or unsupported 'openapi' version field #{doc['openapi'].inspect} " \
379
+ '(expected 3.x)'
380
+ end
381
+ return if doc['paths'].is_a?(Hash) && !doc['paths'].empty?
382
+
383
+ raise Error, 'the document declares no paths — nothing to import'
384
+ end
385
+
386
+ def skip_name?(name, only, except)
387
+ return true if only && !Array(only).map(&:to_sym).include?(name)
388
+ return true if except && Array(except).map(&:to_sym).include?(name)
389
+
390
+ false
391
+ end
392
+
393
+ # A document read from a file: its external $refs are bundled relative to
394
+ # that file (see bundle_external_refs).
395
+ def load_file(path)
396
+ doc = parse_file(path)
397
+ bundle_external_refs(doc, File.expand_path(path)) if doc.is_a?(Hash)
398
+ doc
399
+ end
400
+
401
+ def parse_file(path)
402
+ raise Error, "no such file: #{path}" unless File.file?(path)
403
+
404
+ if YAML.respond_to?(:safe_load_file)
405
+ YAML.safe_load_file(path, aliases: true) || raise(Error, "empty document: #{path}")
406
+ else
407
+ parse_text(File.read(path))
408
+ end
409
+ rescue Psych::SyntaxError => e
410
+ raise Error, "invalid YAML in #{path}: #{e.message}"
411
+ end
412
+
413
+ def parse_text(text)
414
+ stripped = text.to_s.strip
415
+ return {} if stripped.empty?
416
+
417
+ if stripped.start_with?('{', '[')
418
+ begin
419
+ return JSON.parse(stripped)
420
+ rescue JSON::ParserError
421
+ nil # fall through and parse as YAML
422
+ end
423
+ end
424
+ YAML.safe_load(stripped, aliases: true) || raise(Error, 'empty document')
425
+ rescue Psych::SyntaxError => e
426
+ raise Error, "invalid YAML/JSON text: #{e.message}"
427
+ end
428
+
429
+ # External $refs (references into other files) are bundled into the
430
+ # document, so the importer resolves every reference against one document.
431
+ # Each external file is copied once, under EXTERNAL_KEY, and its refs are
432
+ # rewritten to point there. Only local files are read: remote URLs stay
433
+ # unresolved and are reported on the notes, so nothing is fetched.
434
+ def bundle_external_refs(doc, root_path)
435
+ state = { root: doc, root_path: root_path, ids: {}, swagger_ids: {}, visited: {}.compare_by_identity }
436
+ rewrite_refs(state, doc, File.dirname(root_path), '#')
437
+ doc
438
+ end
439
+
440
+ def rewrite_refs(state, node, dir, prefix)
441
+ case node
442
+ when Hash
443
+ return if state[:visited].key?(node)
444
+
445
+ state[:visited][node] = true
446
+ ref = node['$ref']
447
+ if ref.is_a?(String) && !ref.match?(REMOTE_REF)
448
+ node['$ref'] = ref.start_with?('#') ? local_ref(prefix, ref) : external_ref(state, ref, dir)
449
+ end
450
+ # A snapshot: bundling adds EXTERNAL_KEY to the root while it is walked.
451
+ node.to_a.each { |_key, value| rewrite_refs(state, value, dir, prefix) }
452
+ when Array
453
+ node.each { |value| rewrite_refs(state, value, dir, prefix) }
454
+ end
455
+ end
456
+
457
+ # A local ref inside an external file points into that file's copy.
458
+ def local_ref(prefix, ref)
459
+ prefix == '#' ? ref : "#{prefix}#{ref[1..]}"
460
+ end
461
+
462
+ def external_ref(state, ref, dir)
463
+ file, fragment = ref.split('#', 2)
464
+ path = File.expand_path(file, dir)
465
+ return "##{fragment}" if path == state[:root_path]
466
+
467
+ id = external_id(state, path)
468
+ fragment = Swagger2.translate_pointer(fragment) if state[:swagger_ids].key?(id)
469
+ "#/#{EXTERNAL_KEY}/#{id}#{fragment}"
470
+ end
471
+
472
+ # The id of an external file's copy, copying it on first use. The id is
473
+ # assigned before the file's own refs are rewritten, so cycles terminate.
474
+ def external_id(state, path)
475
+ return state[:ids][path] if state[:ids].key?(path)
476
+
477
+ id = state[:ids].size + 1
478
+ state[:ids][path] = id
479
+ target = parse_file(path)
480
+ if Swagger2.swagger?(target)
481
+ target = Swagger2.convert(target)
482
+ state[:swagger_ids][id] = true
483
+ end
484
+ (state[:root][EXTERNAL_KEY] ||= {})[id.to_s] = target
485
+ rewrite_refs(state, target, File.dirname(path), "#/#{EXTERNAL_KEY}/#{id}")
486
+ id
487
+ end
488
+
489
+ # JSON Pointer ($ref) resolution inside the document, with ~0/~1
490
+ # unescaping. Non-local or unresolvable refs return nil (callers
491
+ # degrade to an untyped param plus a note).
492
+ def resolve_pointer(ctx, node, ref, depth: 0)
493
+ return nil if depth > MAX_REF_DEPTH
494
+ return nil unless ref.is_a?(String) && ref.start_with?('#/')
495
+
496
+ node = ctx.document
497
+ ref[2..].split('/').each do |raw|
498
+ token = raw.gsub('~1', '/').gsub('~0', '~')
499
+ node = node.is_a?(Hash) ? node[token] : nil
500
+ return nil if node.nil?
501
+ end
502
+ node
503
+ end
504
+
505
+ def deref(ctx, node, label)
506
+ if node.is_a?(Hash) && node['$ref']
507
+ resolved = resolve_pointer(ctx, node, node['$ref'])
508
+ unless resolved.is_a?(Hash)
509
+ ctx.note("#{label}: unresolvable $ref #{node['$ref'].inspect} — ignored")
510
+ return nil
511
+ end
512
+ return resolved
513
+ end
514
+ node.is_a?(Hash) ? node : nil
515
+ end
516
+
517
+ def derive_name(method, path, op)
518
+ raw = op['operationId']
519
+ base = if raw
520
+ snake_case(raw.to_s)
521
+ else
522
+ segments = path.split('/').reject(&:empty?).map do |seg|
523
+ seg.gsub(/\{([^}]+)\}/, '\1').gsub(/[^a-zA-Z0-9_]/, '_')
524
+ end
525
+ [method, *segments].reject { |s| s.empty? }.join('_')
526
+ end
527
+ base = base.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_')
528
+ base = base.sub(/\A_+/, '').sub(/_+\z/, '')
529
+ base = "_#{base}" if base.match?(/\A\d/)
530
+ base = "#{method}_operation" if base.empty?
531
+ base
532
+ end
533
+
534
+ def snake_case(str)
535
+ s = str.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
536
+ s = s.gsub(/([a-z\d])([A-Z])/, '\1_\2')
537
+ s.tr('-. ', '___').downcase
538
+ end
539
+
540
+ # {id} → :id, validating that placeholders are Ruby identifiers.
541
+ def to_template(path)
542
+ path.gsub(/\{([^}]+)\}/) do
543
+ name = Regexp.last_match(1).strip
544
+ unless name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
545
+ raise Error,
546
+ "path #{path.inspect}: placeholder {#{name}} is not a valid " \
547
+ 'Ruby identifier — rename it or import with a rewritten path'
548
+ end
549
+ ":#{name}"
550
+ end
551
+ end
552
+
553
+ def build_definition(ctx, name, verb, path, op, shared_params)
554
+ template = to_template(path)
555
+ description = op['summary'] || op['description']
556
+ ctx.note("#{op_label(path, verb)}: deprecated operation") if op['deprecated']
557
+ note_servers(op, op_label(path, verb), ctx)
558
+
559
+ definition = Definition.new(name, verb, template, description: description)
560
+
561
+ collect_params(ctx, verb, path, op, shared_params).each_value do |decl|
562
+ definition.param(decl[:name], decl[:type], **decl[:options])
563
+ end
564
+
565
+ style = body_payload_style(ctx, verb, path, op)
566
+ definition.payload_style(style) if style
567
+
568
+ apply_responses(ctx, definition, verb, path, op)
569
+ definition
570
+ end
571
+
572
+ def op_label(path, verb)
573
+ "#{verb.to_s.upcase} #{path}"
574
+ end
575
+
576
+ # --- parameter conversion -------------------------------------------------
577
+
578
+ # Merge path-level and operation-level `parameters` (operation wins on
579
+ # [in, name]) and convert each into a param declaration, then add the
580
+ # request body object properties. Returns an ordered Hash: path params
581
+ # first, then the rest (document order).
582
+ def collect_params(ctx, verb, path, op, shared_params)
583
+ template = to_template(path)
584
+ placeholders = template.scan(Definition::PATH_PLACEHOLDER).map { |m| m[0].to_sym }
585
+
586
+ merged = {}
587
+ Array(shared_params).each do |raw|
588
+ p = deref(ctx, raw, "path #{path} shared parameter")
589
+ next unless p
590
+
591
+ key = [p['in'], p['name']]
592
+ merged[key] = p
593
+ end
594
+ Array(op['parameters']).each do |raw|
595
+ p = deref(ctx, raw, op_label(path, verb))
596
+ next unless p
597
+
598
+ key = [p['in'], p['name']]
599
+ merged[key] = p
600
+ end
601
+
602
+ declarations = {}
603
+ merged.each_value do |p|
604
+ next unless p.is_a?(Hash)
605
+
606
+ location = p['in']
607
+ pname = p['name'].to_s
608
+ label = "#{op_label(path, verb)} #{location} param #{pname.inspect}"
609
+
610
+ if p['content']
611
+ ctx.note("#{label}: media-type parameter (content) is not representable — declare manually")
612
+ next
613
+ end
614
+
615
+ if (location == 'query') && BODY_VERBS.include?(verb) && op['requestBody']
616
+ ctx.note(
617
+ "#{label}: mixes a request body and query parameters — the DSL routes " \
618
+ 'all declared params to one location, so this one will be serialized ' \
619
+ 'into the payload (adjust manually if the vendor requires it in the query)'
620
+ )
621
+ end
622
+
623
+ case location
624
+ when 'path'
625
+ unless pname.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
626
+ ctx.note("#{label}: name is not a valid Ruby identifier — skipped")
627
+ next
628
+ end
629
+ key = pname.to_sym
630
+ unless placeholders.include?(key)
631
+ ctx.note("#{label}: declared but not present in the path template — skipped")
632
+ next
633
+ end
634
+ declarations[key] = param_declaration(ctx, p, label, required: true)
635
+ when 'query'
636
+ unless pname.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
637
+ ctx.note("#{label}: name is not a valid Ruby identifier — skipped")
638
+ next
639
+ end
640
+ declarations[pname.to_sym] = param_declaration(ctx, p, label)
641
+ when 'header', 'cookie'
642
+ declare_transported_param(ctx, declarations, p, label, location)
643
+ else
644
+ ctx.note("#{label}: unknown parameter location — skipped")
645
+ end
646
+ end
647
+
648
+ # Request body object properties come last.
649
+ body = op['requestBody'] ? deref(ctx, op['requestBody'], op_label(path, verb)) : nil
650
+ if body
651
+ if %i[get head].include?(verb)
652
+ ctx.note("#{op_label(path, verb)}: requestBody on #{verb.to_s.upcase} is ignored " \
653
+ '(the DSL sends no body for these verbs)')
654
+ elsif %i[delete options].include?(verb)
655
+ ctx.note("#{op_label(path, verb)}: requestBody on #{verb.to_s.upcase} cannot be sent — " \
656
+ 'the DSL routes params on this verb to the query string')
657
+ else
658
+ collect_body_params(ctx, verb, path, body, declarations)
659
+ end
660
+ end
661
+
662
+ declarations
663
+ end
664
+
665
+ def param_declaration(ctx, p, label, required: false)
666
+ schema = merge_all_of(ctx, deref(ctx, p['schema'] || {}, label) || {}, label)
667
+ note_nullable(ctx, schema, label)
668
+
669
+ type, type_note = map_type(ctx, schema, label)
670
+ ctx.note("#{label}: #{type_note}") if type_note
671
+
672
+ options = constraint_options(ctx, schema, label)
673
+ options[:required] = true if required || p['required'] == true
674
+ options[:description] = p['description'] || schema['description']
675
+
676
+ { name: p['name'].to_sym, type: type, options: options }
677
+ end
678
+
679
+ # A header or cookie param becomes a param with a header: or cookie: option.
680
+ # Its name must be a token and its type a scalar; the Ruby name is the
681
+ # snake-cased wire name. Anything else is reported on the notes, not guessed.
682
+ def declare_transported_param(ctx, declarations, p, label, location)
683
+ wire = p['name'].to_s
684
+ unless wire.match?(Param::HTTP_TOKEN)
685
+ kind = location == 'header' ? 'HTTP header' : 'cookie'
686
+ return ctx.note("#{label}: name #{wire.inspect} is not a valid #{kind} name — skipped")
687
+ end
688
+
689
+ decl = param_declaration(ctx, p, label)
690
+ unless [String, Integer, Float, :boolean].include?(decl[:type])
691
+ return ctx.note("#{label}: not representable as a scalar #{location} value — skipped")
692
+ end
693
+
694
+ key = header_param_key(wire)
695
+ if key.nil? || declarations.key?(key)
696
+ return ctx.note("#{label}: name #{wire.inspect} collides with another param — skipped")
697
+ end
698
+
699
+ decl[:name] = key
700
+ decl[:options][location == 'header' ? :header : :cookie] = wire
701
+ declarations[key] = decl
702
+ end
703
+
704
+ # 'X-Request-Id' → :x_request_id. Nil when nothing identifier-like remains.
705
+ def header_param_key(wire)
706
+ key = wire.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '')
707
+ return nil if key.empty?
708
+
709
+ key.match?(/\A\d/) ? :"_#{key}" : key.to_sym
710
+ end
711
+
712
+ def collect_body_params(ctx, verb, path, body, declarations)
713
+ content = body['content'].is_a?(Hash) ? body['content'] : {}
714
+ media = find_media(content, 'application/json') ||
715
+ find_media(content, 'application/x-www-form-urlencoded')
716
+ return unless media
717
+
718
+ label = op_label(path, verb)
719
+ schema = merge_all_of(ctx, deref(ctx, media[1]['schema'], "#{label} request body") || {},
720
+ "#{label} request body")
721
+ note_nullable(ctx, schema, "#{label} request body")
722
+
723
+ if schema['type'] && schema['type'] != 'object'
724
+ ctx.note("#{label}: request body is a #{schema['type']} — object properties " \
725
+ 'cannot be derived; declare params manually')
726
+ return
727
+ end
728
+ if schema['oneOf'] || schema['anyOf'] || schema['allOf']
729
+ ctx.note("#{label}: request body is a schema combination (oneOf/anyOf/allOf) — " \
730
+ 'declare params manually')
731
+ return
732
+ end
733
+
734
+ required_fields = Array(schema['required']).map(&:to_s)
735
+ properties = schema['properties'].is_a?(Hash) ? schema['properties'] : {}
736
+ properties.each do |pname, raw_schema|
737
+ next unless pname.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
738
+
739
+ prop_label = "#{label} body param #{pname.to_s.inspect}"
740
+ prop_schema = merge_all_of(ctx, deref(ctx, raw_schema, prop_label) || {}, prop_label)
741
+ note_nullable(ctx, prop_schema, prop_label)
742
+ type, type_note = map_type(ctx, prop_schema, prop_label)
743
+ ctx.note("#{prop_label}: #{type_note}") if type_note
744
+
745
+ options = constraint_options(ctx, prop_schema, prop_label)
746
+ options[:required] = true if required_fields.include?(pname.to_s)
747
+ options[:description] = prop_schema['description']
748
+
749
+ declarations[pname.to_sym] = { name: pname.to_sym, type: type, options: options }
750
+ end
751
+ end
752
+
753
+ # Flatten an allOf into one schema. Object members merge their properties
754
+ # (the first definition of a name wins) and their required lists; a single
755
+ # scalar member, with no other typed member, is unwrapped with the outer
756
+ # keys (description, default) laid over it. Anything else is returned
757
+ # unchanged, so the caller's combinator check reports it.
758
+ def merge_all_of(ctx, schema, label, depth: 0)
759
+ return schema unless schema.is_a?(Hash) && schema['allOf'].is_a?(Array)
760
+ return schema if depth > MAX_REF_DEPTH
761
+
762
+ members = schema['allOf'].map { |raw| deref(ctx, raw, label) }
763
+ return schema if members.any?(&:nil?)
764
+
765
+ members = members.map { |m| merge_all_of(ctx, m, label, depth: depth + 1) }
766
+ return schema if members.any? { |m| (m.keys & %w[oneOf anyOf not]).any? }
767
+
768
+ own = schema.except('allOf')
769
+ typed = members.reject { |m| m['type'].nil? && !m.key?('properties') }
770
+ if typed.empty?
771
+ own
772
+ elsif typed.all? { |m| m['type'].nil? || m['type'] == 'object' }
773
+ merge_object_members(own, typed)
774
+ elsif typed.size == 1
775
+ typed.first.merge(own)
776
+ else
777
+ schema
778
+ end
779
+ end
780
+
781
+ def merge_object_members(own, members)
782
+ properties = {}
783
+ required = []
784
+ [own, *members].each do |part|
785
+ part_properties = part['properties'].is_a?(Hash) ? part['properties'] : {}
786
+ part_properties.each { |name, prop| properties[name] = prop unless properties.key?(name) }
787
+ required |= Array(part['required']).map(&:to_s)
788
+ end
789
+ own.merge('type' => 'object', 'properties' => properties, 'required' => required)
790
+ end
791
+
792
+ # Schema → [param type, note]. Untyped/unsupported combinations yield
793
+ # [nil, note] — the param stays untyped instead of guessed.
794
+ def map_type(ctx, schema, label, depth: 0)
795
+ return [nil, 'schema nesting too deep — left untyped'] if depth > MAX_REF_DEPTH
796
+
797
+ if (ref = schema['$ref'])
798
+ resolved = resolve_pointer(ctx, schema, ref)
799
+ return [nil, "unresolvable $ref #{ref.inspect} — left untyped"] unless resolved.is_a?(Hash)
800
+
801
+ return map_type(ctx, resolved, label, depth: depth + 1)
802
+ end
803
+
804
+ %w[oneOf anyOf allOf not].each do |combinator|
805
+ return [nil, "#{combinator} schemas are not representable — left untyped"] if schema[combinator]
806
+ end
807
+
808
+ case schema['type']
809
+ when 'string' then [String, nil]
810
+ when 'integer' then [Integer, nil]
811
+ when 'number' then [Float, nil]
812
+ when 'boolean' then [:boolean, nil]
813
+ when 'array' then [Array, nil]
814
+ when 'object' then [Hash, nil]
815
+ when nil then [nil, nil]
816
+ else [nil, "type #{schema['type'].inspect} is not representable — left untyped"]
817
+ end
818
+ end
819
+
820
+ def note_nullable(ctx, schema, label)
821
+ return unless schema.is_a?(Hash) && schema['nullable'] == true
822
+
823
+ ctx.note("#{label}: nullable maps to 'param absent' semantics (a nil value " \
824
+ 'behaves like a missing param)')
825
+ end
826
+
827
+ # JSON Schema constraints → contract param options (min:/max:/length:/
828
+ # pattern:/inclusion:/default:). Anything that cannot be expressed
829
+ # faithfully produces a note instead of a wrong constraint.
830
+ def constraint_options(ctx, schema, label)
831
+ options = {}
832
+ options[:inclusion] = schema['enum'] if schema['enum'].is_a?(Array)
833
+
834
+ # OpenAPI 3.0: minimum + exclusiveMinimum:true. OpenAPI 3.1 allows a
835
+ # numeric exclusiveMinimum instead of minimum. Only integer schemas
836
+ # can absorb exclusivity by shifting the bound by one; a number
837
+ # (float) schema is approximated with an inclusive bound and a note.
838
+ integer_type = schema['type'] == 'integer'
839
+ if schema.key?('minimum') || schema['exclusiveMinimum'].is_a?(Numeric)
840
+ bound = schema.key?('minimum') ? schema['minimum'] : schema['exclusiveMinimum']
841
+ exclusive = schema['exclusiveMinimum'] == true || schema['exclusiveMinimum'].is_a?(Numeric)
842
+ if exclusive && integer_type && bound.is_a?(Integer)
843
+ options[:min] = bound + 1
844
+ else
845
+ ctx.note("#{label}: exclusiveMinimum approximated as an inclusive bound") if exclusive
846
+ options[:min] = bound if bound
847
+ end
848
+ end
849
+
850
+ if schema.key?('maximum') || schema['exclusiveMaximum'].is_a?(Numeric)
851
+ bound = schema.key?('maximum') ? schema['maximum'] : schema['exclusiveMaximum']
852
+ exclusive = schema['exclusiveMaximum'] == true || schema['exclusiveMaximum'].is_a?(Numeric)
853
+ if exclusive && integer_type && bound.is_a?(Integer)
854
+ options[:max] = bound - 1
855
+ else
856
+ ctx.note("#{label}: exclusiveMaximum approximated as an inclusive bound") if exclusive
857
+ options[:max] = bound if bound
858
+ end
859
+ end
860
+
861
+ lo = schema['minLength']
862
+ hi = schema['maxLength']
863
+ if lo || hi
864
+ if lo && hi
865
+ options[:length] = lo == hi ? lo : (lo..hi)
866
+ else
867
+ ctx.note("#{label}: #{lo ? 'minLength' : 'maxLength'} without the other is not " \
868
+ 'expressible (length: needs both bounds) — skipped')
869
+ end
870
+ end
871
+
872
+ if (pattern = schema['pattern'])
873
+ # OpenAPI `pattern` is a JSON Schema/ecma-262 regex, not a Ruby
874
+ # Regexp — but we materialize it as a Ruby Regexp so generated
875
+ # contracts can call it as a verifier. A document with a
876
+ # catastrophic-backtracking pattern (e.g. `^(a+)+$`) would hang
877
+ # the importer on any input that exercises it. Wrap Regexp.new
878
+ # in a 1-second Timeout so a hostile pattern fails fast
879
+ # instead of wedging the import. The pattern is still compiled
880
+ # and stored on the contract — the timeout only bounds the
881
+ # compilation probe.
882
+ begin
883
+ compiled = compile_pattern_with_timeout(pattern)
884
+ options[:pattern] = compiled if compiled
885
+ rescue RegexpError => e
886
+ ctx.note("#{label}: pattern #{pattern.inspect} is not a valid Ruby Regexp (#{e.message}) — skipped")
887
+ rescue Timeout::Error
888
+ ctx.note("#{label}: pattern #{pattern.inspect} took longer than " \
889
+ "#{PATTERN_COMPILE_TIMEOUT_SECONDS}s to compile — " \
890
+ 'possible catastrophic backtracking; skipped')
891
+ end
892
+ end
893
+
894
+ if schema.key?('default')
895
+ default = schema['default']
896
+ options[:default] = default
897
+ if options[:inclusion] && !options[:inclusion].include?(default)
898
+ ctx.note("#{label}: default #{default.inspect} is not a member of the enum")
899
+ end
900
+ end
901
+
902
+ options
903
+ end
904
+
905
+ # --- payload style --------------------------------------------------------
906
+
907
+ # The first entry of a content map whose media type (ignoring parameters
908
+ # such as charset) is +wanted+, or nil.
909
+ def find_media(content, wanted)
910
+ content.find { |type, _| type.to_s.split(';').first.to_s.strip == wanted }
911
+ end
912
+
913
+ # Choose payload_style from the request body content:
914
+ # application/json → :json (the DSL default; no line)
915
+ # application/x-www-form-urlencoded → :form
916
+ # multipart/* → noted, not mapped
917
+ # No body + query params on a body verb → :query so params hit the URL.
918
+ def body_payload_style(ctx, verb, path, op)
919
+ label = op_label(path, verb)
920
+ body = op['requestBody'] ? deref(ctx, op['requestBody'], label) : nil
921
+
922
+ if body
923
+ content = body['content'].is_a?(Hash) ? body['content'] : {}
924
+ # JSON wins when both are offered, matching collect_body_params.
925
+ return nil if find_media(content, 'application/json')
926
+ return :form if find_media(content, 'application/x-www-form-urlencoded')
927
+
928
+ ctx.note("#{label}: request body content types " \
929
+ "[#{content.keys.map(&:inspect).join(', ')}] are not representable as " \
930
+ 'contract params (multipart/raw bodies need manual payload handling)')
931
+ return nil
932
+ end
933
+
934
+ has_query = Array(op['parameters']).any? do |raw|
935
+ p = deref(ctx, raw, label)
936
+ p && p['in'] == 'query'
937
+ end
938
+ return :query if BODY_VERBS.include?(verb) && has_query
939
+
940
+ nil
941
+ end
942
+
943
+ # --- responses --------------------------------------------------------------
944
+
945
+ def apply_responses(ctx, definition, verb, path, op)
946
+ responses = op['responses'].is_a?(Hash) ? op['responses'] : {}
947
+ return if responses.empty?
948
+
949
+ entries = []
950
+ responses.each do |code, raw|
951
+ label = "#{op_label(path, verb)} response #{code.inspect}"
952
+ resp = deref(ctx, raw, label)
953
+ next unless resp.is_a?(Hash)
954
+
955
+ if code == 'default'
956
+ ctx.note("#{label}: the 'default' response is not representable — skipped")
957
+ next
958
+ end
959
+
960
+ status = parse_status(ctx, code, op_label(path, verb))
961
+ next unless status
962
+
963
+ entries << [status, resp, label]
964
+ end
965
+
966
+ return if entries.empty?
967
+
968
+ statuses = entries.map { |s, _| s }.sort_by { |s| s.is_a?(Range) ? s.min : s }
969
+
970
+ # Type + response fields come from the first 2xx entry with an
971
+ # application/json schema; every other entry only contributes its
972
+ # status.
973
+ source = entries.find do |status, resp, _|
974
+ next false unless status2xx?(status)
975
+
976
+ content = resp.is_a?(Hash) ? resp['content'] : nil
977
+ content.is_a?(Hash) &&
978
+ content.any? { |t, _| t.to_s.split(';').first.to_s.strip == 'application/json' }
979
+ end
980
+
981
+ type = nil
982
+ fields_schema = nil
983
+ if source
984
+ schema, = json_schema_of(ctx, source[1], source[2])
985
+ schema = merge_all_of(ctx, schema, source[2])
986
+ type = case schema['type']
987
+ when 'array' then :array
988
+ when 'object' then :hash
989
+ when 'string' then :string
990
+ else :json
991
+ end
992
+ fields_schema = schema if schema['type'] == 'object'
993
+ end
994
+
995
+ definition.responds(*statuses, type: type)
996
+ apply_response_fields(ctx, definition, fields_schema) if fields_schema
997
+ end
998
+
999
+ def status2xx?(status)
1000
+ status.is_a?(Integer) ? (200..299).cover?(status) : status.min >= 200 && status.max <= 299
1001
+ end
1002
+
1003
+ def parse_status(ctx, code, label)
1004
+ case code.to_s
1005
+ when /\A([1-5])\d{2}\z/
1006
+ code.to_i
1007
+ when /\A([1-5])XX\z/
1008
+ n = Regexp.last_match(1).to_i * 100
1009
+ (n..(n + 99))
1010
+ else
1011
+ ctx.note("#{label}: response code #{code.inspect} is not a numeric status — skipped")
1012
+ nil
1013
+ end
1014
+ end
1015
+
1016
+ # Returns [schema, media_type] for the first application/json entry.
1017
+ def json_schema_of(ctx, response, label)
1018
+ content = response['content'].is_a?(Hash) ? response['content'] : {}
1019
+ media = content.find do |type, _|
1020
+ type.to_s.split(';').first.to_s.strip == 'application/json'
1021
+ end
1022
+ return nil unless media
1023
+
1024
+ schema = deref(ctx, media[1].is_a?(Hash) ? media[1]['schema'] : nil, label) || {}
1025
+ [schema, media[0]]
1026
+ end
1027
+
1028
+ def apply_response_fields(ctx, definition, schema)
1029
+ required_fields = Array(schema['required']).map(&:to_s)
1030
+ properties = schema['properties'].is_a?(Hash) ? schema['properties'] : {}
1031
+ properties.each do |pname, raw_schema|
1032
+ next unless pname.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
1033
+
1034
+ label = "#{definition.name} response field #{pname.to_s.inspect}"
1035
+ field_schema = merge_all_of(ctx, deref(ctx, raw_schema, label) || {}, label)
1036
+ type, type_note = map_type(ctx, field_schema, label)
1037
+ ctx.note("#{label}: #{type_note}") if type_note
1038
+
1039
+ options = {}
1040
+ options[:required] = true if required_fields.include?(pname.to_s)
1041
+ options[:inclusion] = field_schema['enum'] if field_schema['enum'].is_a?(Array)
1042
+ options[:description] = field_schema['description']
1043
+ definition.response_field(pname.to_sym, type, **options)
1044
+ end
1045
+ end
1046
+
1047
+ # --- servers & security ------------------------------------------------------
1048
+
1049
+ def note_servers(node, label, ctx)
1050
+ return unless node.is_a?(Hash) && node['servers']
1051
+
1052
+ ctx.note("#{label}: operation-level servers override the base URL — not representable, " \
1053
+ 'configure the client manually if needed')
1054
+ end
1055
+
1056
+ def resolve_base_url(doc, ctx)
1057
+ server = Array(doc['servers']).first
1058
+ return nil unless server.is_a?(Hash)
1059
+
1060
+ url = server['url'].to_s
1061
+ variables = server['variables'].is_a?(Hash) ? server['variables'] : {}
1062
+ url = url.gsub(/\{([a-zA-Z0-9_]+)\}/) do
1063
+ name = Regexp.last_match(1)
1064
+ variable = variables[name]
1065
+ variable.is_a?(Hash) && variable.key?('default') ? variable['default'].to_s : "{#{name}}"
1066
+ end
1067
+
1068
+ unless url.match?(%r{\Ahttps?://}i)
1069
+ ctx.note("server URL #{url.inspect} is not an absolute http(s) URL — no `base` " \
1070
+ 'emitted, configure the client manually')
1071
+ return nil
1072
+ end
1073
+
1074
+ url.sub(%r{/+\z}, '')
1075
+ end
1076
+
1077
+ # Auth is never invented: securitySchemes produce commented-out HINTS
1078
+ # (rendered into generated source) so credentials stay the user's job.
1079
+ def derive_auth_hints(doc, ctx)
1080
+ schemes = doc.dig('components', 'securitySchemes')
1081
+ return [] unless schemes.is_a?(Hash)
1082
+
1083
+ hints = []
1084
+ schemes.each do |name, raw|
1085
+ scheme = deref(ctx, raw, "securityScheme #{name.inspect}")
1086
+ next unless scheme
1087
+
1088
+ env = name.to_s.upcase.gsub(/[^A-Z0-9]+/, '_')
1089
+ case scheme['type']
1090
+ when 'http'
1091
+ case scheme['scheme'].to_s
1092
+ when 'bearer'
1093
+ hints << "auth bearer: -> { ENV['#{env}_TOKEN'] } # securityScheme: #{name}"
1094
+ when 'basic'
1095
+ hints << "auth basic: -> { [ENV['#{env}_USER'], ENV['#{env}_PASSWORD']] } " \
1096
+ "# securityScheme: #{name}"
1097
+ else
1098
+ ctx.note("securityScheme #{name.inspect}: http scheme " \
1099
+ "#{scheme['scheme'].inspect} has no DSL equivalent — skipped")
1100
+ end
1101
+ when 'apiKey'
1102
+ case scheme['in']
1103
+ when 'header'
1104
+ hints << "auth api_key: { name: #{scheme['name'].inspect}, " \
1105
+ "value: -> { ENV['#{env}'] } } # securityScheme: #{name}"
1106
+ when 'query'
1107
+ hints << "auth api_key: { name: #{scheme['name'].inspect}, " \
1108
+ "value: -> { ENV['#{env}'] }, in: :query } # securityScheme: #{name}"
1109
+ else
1110
+ ctx.note("securityScheme #{name.inspect}: apiKey in #{scheme['in'].inspect} " \
1111
+ 'is not representable — skipped')
1112
+ end
1113
+ when 'oauth2', 'openIdConnect'
1114
+ ctx.note("securityScheme #{name.inspect}: OAuth flows are not auto-generated — " \
1115
+ 'supply a token source via auth bearer: or a before_request callback')
1116
+ end
1117
+ end
1118
+ hints
1119
+ end
1120
+
1121
+ # Compile a JSON Schema `pattern` into a Ruby Regexp, but bound
1122
+ # the compile (and a tiny matching probe) to
1123
+ # PATTERN_COMPILE_TIMEOUT_SECONDS. A document with a
1124
+ # catastrophic-backtracking pattern (e.g. `^(a+)+$`) would
1125
+ # otherwise hang the importer; the timeout lets us bail out,
1126
+ # note the pattern as unrepresentable, and continue.
1127
+ def compile_pattern_with_timeout(pattern)
1128
+ Timeout.timeout(PATTERN_COMPILE_TIMEOUT_SECONDS) do
1129
+ Regexp.new(pattern)
1130
+ end
1131
+ end
1132
+ end
1133
+ end
1134
+ end
1135
+ # rubocop:enable Naming/MethodParameterName