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.
- checksums.yaml +7 -0
- data/API.md +142 -0
- data/ARCHITECTURE.md +134 -0
- data/AUTHORS +106 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +1290 -0
- data/SECURITY.md +67 -0
- data/bin/contractful +128 -0
- data/history.md +801 -0
- data/lib/contractful/abstract_response.rb +301 -0
- data/lib/contractful/circuit_breaker.rb +221 -0
- data/lib/contractful/connection_pool.rb +166 -0
- data/lib/contractful/contract/auth.rb +257 -0
- data/lib/contractful/contract/config.rb +93 -0
- data/lib/contractful/contract/definition.rb +295 -0
- data/lib/contractful/contract/dsl.rb +501 -0
- data/lib/contractful/contract/executor.rb +492 -0
- data/lib/contractful/contract/openapi_import.rb +1135 -0
- data/lib/contractful/contract/pagination.rb +218 -0
- data/lib/contractful/contract/param.rb +505 -0
- data/lib/contractful/contract/postman_import/auth.rb +39 -0
- data/lib/contractful/contract/postman_import/body.rb +87 -0
- data/lib/contractful/contract/postman_import/operation.rb +107 -0
- data/lib/contractful/contract/postman_import/scope.rb +37 -0
- data/lib/contractful/contract/postman_import/url.rb +80 -0
- data/lib/contractful/contract/postman_import.rb +164 -0
- data/lib/contractful/contract/response_field.rb +167 -0
- data/lib/contractful/contract/sequence.rb +432 -0
- data/lib/contractful/contract/swagger2/components.rb +84 -0
- data/lib/contractful/contract/swagger2/operations.rb +81 -0
- data/lib/contractful/contract/swagger2/parameters.rb +93 -0
- data/lib/contractful/contract/swagger2/references.rb +55 -0
- data/lib/contractful/contract/swagger2.rb +55 -0
- data/lib/contractful/contract.rb +85 -0
- data/lib/contractful/exceptions.rb +328 -0
- data/lib/contractful/notifications/active_support.rb +40 -0
- data/lib/contractful/notifications.rb +138 -0
- data/lib/contractful/pacer.rb +161 -0
- data/lib/contractful/params_array.rb +85 -0
- data/lib/contractful/payload.rb +340 -0
- data/lib/contractful/platform.rb +52 -0
- data/lib/contractful/rate_limit.rb +133 -0
- data/lib/contractful/raw_response.rb +70 -0
- data/lib/contractful/request.rb +1483 -0
- data/lib/contractful/resource.rb +189 -0
- data/lib/contractful/response.rb +109 -0
- data/lib/contractful/utils.rb +202 -0
- data/lib/contractful/version.rb +11 -0
- data/lib/contractful/windows/root_certs.rb +107 -0
- data/lib/contractful/windows.rb +9 -0
- data/lib/contractful.rb +233 -0
- 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
|