openapi_kit-codegen 0.1.0.pre.1
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/LICENSE.txt +21 -0
- data/README.md +385 -0
- data/exe/openapi_kit +6 -0
- data/lib/openapi_kit/codegen/cli.rb +72 -0
- data/lib/openapi_kit/codegen/config.rb +114 -0
- data/lib/openapi_kit/codegen/emit/buffer.rb +92 -0
- data/lib/openapi_kit/codegen/emit/codecs.rb +221 -0
- data/lib/openapi_kit/codegen/emit/controllers.rb +263 -0
- data/lib/openapi_kit/codegen/emit/decode.rb +43 -0
- data/lib/openapi_kit/codegen/emit/defaults.rb +42 -0
- data/lib/openapi_kit/codegen/emit/emitter.rb +17 -0
- data/lib/openapi_kit/codegen/emit/forms.rb +56 -0
- data/lib/openapi_kit/codegen/emit/handlers.rb +65 -0
- data/lib/openapi_kit/codegen/emit/literal.rb +28 -0
- data/lib/openapi_kit/codegen/emit/operations.rb +277 -0
- data/lib/openapi_kit/codegen/emit/registry.rb +101 -0
- data/lib/openapi_kit/codegen/emit/routes.rb +77 -0
- data/lib/openapi_kit/codegen/emit/security.rb +183 -0
- data/lib/openapi_kit/codegen/emit/source_file.rb +30 -0
- data/lib/openapi_kit/codegen/emit/types.rb +153 -0
- data/lib/openapi_kit/codegen/generator.rb +48 -0
- data/lib/openapi_kit/codegen/loader.rb +730 -0
- data/lib/openapi_kit/codegen/model/document.rb +302 -0
- data/lib/openapi_kit/codegen/model/schema.rb +110 -0
- data/lib/openapi_kit/codegen/model/type_def.rb +89 -0
- data/lib/openapi_kit/codegen/naming.rb +78 -0
- data/lib/openapi_kit/codegen/ruby_type.rb +48 -0
- data/lib/openapi_kit/codegen/type_registry.rb +325 -0
- data/lib/openapi_kit/codegen/writer.rb +92 -0
- data/lib/openapi_kit/codegen.rb +37 -0
- data/lib/openapi_kit-codegen.rb +4 -0
- metadata +118 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "openapi3_parser"
|
|
5
|
+
|
|
6
|
+
module OpenAPIKit
|
|
7
|
+
module Codegen
|
|
8
|
+
class Loader
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
VERBS = T.let(%w[get put post delete options head patch trace].freeze, T::Array[String])
|
|
12
|
+
|
|
13
|
+
# The media types Rails parses into request_parameters and openapi_kit renders as JSON.
|
|
14
|
+
# Anything else would decode a Hash that was never there, so it is refused.
|
|
15
|
+
OAUTH_FLOWS = T.let(
|
|
16
|
+
%i[implicit password client_credentials authorization_code].freeze,
|
|
17
|
+
T::Array[Symbol]
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
MULTIPART_SHAPE = T.let(
|
|
21
|
+
"A multipart body must be an object whose properties are the fields it accepts.",
|
|
22
|
+
String
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DECODABLE_MEDIA_TYPES = T.let(
|
|
26
|
+
%w[application/json application/x-www-form-urlencoded multipart/form-data].freeze,
|
|
27
|
+
T::Array[String]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
sig { returns(T::Array[String]) }
|
|
31
|
+
def warnings = @warnings.to_a
|
|
32
|
+
|
|
33
|
+
sig { params(config: Config).void }
|
|
34
|
+
def initialize(config:)
|
|
35
|
+
@config = config
|
|
36
|
+
@warnings = T.let(Set.new, T::Set[String])
|
|
37
|
+
@types = T.let({}, T::Hash[String, Model::TypeDef])
|
|
38
|
+
@keys_by_name = T.let({}, T::Hash[String, String])
|
|
39
|
+
@in_progress = T.let(Set.new, T::Set[String])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(Model::Document) }
|
|
43
|
+
def parse
|
|
44
|
+
document = Openapi3Parser.load_file(@config.spec)
|
|
45
|
+
unless document.valid?
|
|
46
|
+
details = document.errors.map { |e| " #{e.context&.location_summary}: #{e.message}" }
|
|
47
|
+
raise SchemaError, "#{@config.spec} is not a valid OpenAPI document:\n#{details.join("\n")}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
operations = build_operations(document)
|
|
51
|
+
document.components&.schemas&.each { |name, node| schema_for(node, hint: name) }
|
|
52
|
+
|
|
53
|
+
reject_untypeable_security!(operations, requirements(document) || [])
|
|
54
|
+
|
|
55
|
+
Model::Document.new(
|
|
56
|
+
title: document.info.title,
|
|
57
|
+
version: document.info.version,
|
|
58
|
+
types: @types.values,
|
|
59
|
+
operations: operations,
|
|
60
|
+
security_schemes: build_security_schemes(document),
|
|
61
|
+
security: requirements(document) || []
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
sig { params(document: Openapi3Parser::Document).returns(T::Array[Model::Operation]) }
|
|
68
|
+
def build_operations(document)
|
|
69
|
+
operations = document.paths.flat_map do |path, item|
|
|
70
|
+
VERBS.filter_map do |verb|
|
|
71
|
+
node = item.public_send(verb)
|
|
72
|
+
node && build_operation(node, path: path, verb: verb, item: item)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
reject_collisions!(
|
|
77
|
+
operations.map(&:id),
|
|
78
|
+
subject: "operationIds",
|
|
79
|
+
consequence: "each operation needs its own handler method, types and route"
|
|
80
|
+
)
|
|
81
|
+
reject_collisions!(
|
|
82
|
+
operations.map(&:tag),
|
|
83
|
+
subject: "tags",
|
|
84
|
+
consequence: "each tag needs its own handler interface and controller"
|
|
85
|
+
)
|
|
86
|
+
operations.each { |operation| reject_misplaced_binary!(operation) }
|
|
87
|
+
operations
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
sig { params(operation: Model::Operation).void }
|
|
91
|
+
def reject_misplaced_binary!(operation)
|
|
92
|
+
operation.parameters.each do |parameter|
|
|
93
|
+
info = Model::Parameter.info(parameter)
|
|
94
|
+
reject_binary_within!(info.schema, "parameter #{info.name.inspect} of #{operation.id}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
operation.request_body&.contents&.each { |content| reject_binary_in_request!(content, operation) }
|
|
98
|
+
operation.responses.each { |response| reject_binary_in_response!(response, operation) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sig { params(content: Model::Content, operation: Model::Operation).void }
|
|
102
|
+
def reject_binary_in_request!(content, operation)
|
|
103
|
+
schema = content.schema
|
|
104
|
+
return if schema.nil?
|
|
105
|
+
|
|
106
|
+
where = "the request body of #{operation.id}"
|
|
107
|
+
return reject_binary_within!(schema, where) unless content.multipart?
|
|
108
|
+
|
|
109
|
+
reject_binary!(schema, where)
|
|
110
|
+
form_fields(schema).each do |property|
|
|
111
|
+
next if Model::Schema.file?(property.schema)
|
|
112
|
+
|
|
113
|
+
reject_binary_within!(property.schema, "#{property.name.inspect} in #{where}")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
sig { params(response: Model::Response, operation: Model::Operation).void }
|
|
118
|
+
def reject_binary_in_response!(response, operation)
|
|
119
|
+
where = "the #{Model::Status.constant(response.status)} response of #{operation.id}"
|
|
120
|
+
|
|
121
|
+
response.headers.each do |header|
|
|
122
|
+
reject_binary_within!(header.schema, "header #{header.name.inspect} of #{where}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
response.contents.each do |content|
|
|
126
|
+
schema = content.schema
|
|
127
|
+
next if schema.nil? || Model::Schema.file?(schema)
|
|
128
|
+
|
|
129
|
+
reject_binary_within!(schema, where)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
sig { params(schema: Model::Schema, where: String, seen: T::Set[String]).void }
|
|
134
|
+
def reject_binary_within!(schema, where, seen = Set.new)
|
|
135
|
+
case schema
|
|
136
|
+
when Model::StringSchema then reject_binary!(schema, where)
|
|
137
|
+
when Model::Ref then reject_binary_in_type!(schema.name, where, seen)
|
|
138
|
+
when Model::List then reject_binary_within!(schema.items, where, seen)
|
|
139
|
+
when Model::Freeform
|
|
140
|
+
values = schema.values
|
|
141
|
+
reject_binary_within!(values, where, seen) unless values.nil?
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sig { params(name: String, where: String, seen: T::Set[String]).void }
|
|
146
|
+
def reject_binary_in_type!(name, where, seen)
|
|
147
|
+
return unless seen.add?(name)
|
|
148
|
+
|
|
149
|
+
case (type = type_named(name))
|
|
150
|
+
when Model::ObjectDef, Model::FormDef
|
|
151
|
+
type.properties.each { |property| reject_binary_within!(property.schema, where, seen) }
|
|
152
|
+
extra = type.additional_properties
|
|
153
|
+
reject_binary_within!(extra, where, seen) unless extra.nil?
|
|
154
|
+
when Model::UnionDef then type.members.each { |member| reject_binary_within!(member, where, seen) }
|
|
155
|
+
when Model::AliasDef then reject_binary_within!(type.target, where, seen)
|
|
156
|
+
when Model::EnumDef, nil then nil
|
|
157
|
+
else T.absurd(type)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
sig { params(schema: Model::Schema, where: String).void }
|
|
162
|
+
def reject_binary!(schema, where)
|
|
163
|
+
return unless Model::Schema.file?(schema)
|
|
164
|
+
|
|
165
|
+
raise SchemaError,
|
|
166
|
+
"#{where} declares format: binary, which cannot be produced there. It is only " \
|
|
167
|
+
"valid as a top-level property of a multipart/form-data request body, or as the " \
|
|
168
|
+
"whole schema of a response body. Use format: byte to carry bytes inside JSON, " \
|
|
169
|
+
"or x-ruby-type to name a type of your own."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
sig { params(schema: Model::Schema).returns(T::Array[Model::Property]) }
|
|
173
|
+
def form_fields(schema)
|
|
174
|
+
return [] unless schema.is_a?(Model::Ref)
|
|
175
|
+
|
|
176
|
+
case (type = type_named(schema.name))
|
|
177
|
+
when Model::ObjectDef, Model::FormDef then type.properties
|
|
178
|
+
else []
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
sig { params(name: String).returns(T.nilable(Model::TypeDef)) }
|
|
183
|
+
def type_named(name)
|
|
184
|
+
key = @keys_by_name[name]
|
|
185
|
+
key && @types[key]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# With principals configured, openapi_kit resolves the alternatives itself, so it has to be
|
|
189
|
+
# able to name the type each one produces.
|
|
190
|
+
sig do
|
|
191
|
+
params(operations: T::Array[Model::Operation],
|
|
192
|
+
fallback: T::Array[Model::SecurityRequirement]).void
|
|
193
|
+
end
|
|
194
|
+
def reject_untypeable_security!(operations, fallback)
|
|
195
|
+
operations.each do |operation|
|
|
196
|
+
(operation.security || fallback).each do |requirement|
|
|
197
|
+
reject_untypeable_requirement!(operation, requirement)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
sig { params(operation: Model::Operation, requirement: Model::SecurityRequirement).void }
|
|
203
|
+
def reject_untypeable_requirement!(operation, requirement)
|
|
204
|
+
if requirement.schemes.size > 1
|
|
205
|
+
raise SchemaError,
|
|
206
|
+
"#{operation.id} requires #{requirement.schemes.keys.join(" and ")} together. " \
|
|
207
|
+
"openapi_kit cannot yet name the type that produces: give the operation one scheme per " \
|
|
208
|
+
"alternative, or drop `principals` and authenticate in your base controller."
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
return unless @config.principal.nil?
|
|
212
|
+
|
|
213
|
+
raise ConfigError,
|
|
214
|
+
"#{operation.id} declares security, so a successful authentication produces a " \
|
|
215
|
+
"principal, but no `principal` is configured. Name the class it produces, " \
|
|
216
|
+
"e.g. principal: \"MyApp::Principal\"."
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
sig { params(names: T::Array[String], subject: String, consequence: String).void }
|
|
220
|
+
def reject_collisions!(names, subject:, consequence:)
|
|
221
|
+
names.uniq.group_by { |name| Naming.snake(name) }.each do |normalised, originals|
|
|
222
|
+
next if originals.size == 1
|
|
223
|
+
|
|
224
|
+
raise SchemaError,
|
|
225
|
+
"#{subject} #{originals.sort.join(", ")} all generate the name #{normalised}. " \
|
|
226
|
+
"Rename all but one: #{consequence}."
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
sig do
|
|
231
|
+
params(node: Openapi3Parser::Node::Operation, path: String, verb: String,
|
|
232
|
+
item: Openapi3Parser::Node::PathItem).returns(Model::Operation)
|
|
233
|
+
end
|
|
234
|
+
def build_operation(node, path:, verb:, item:)
|
|
235
|
+
id = node.operation_id
|
|
236
|
+
if id.nil? || id.to_s.empty?
|
|
237
|
+
raise SchemaError,
|
|
238
|
+
"#{verb.upcase} #{path} has no operationId. Every operation needs one: it names " \
|
|
239
|
+
"the handler method, the request and response types, and the route."
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
base = Naming.pascal(id)
|
|
243
|
+
body = node.request_body
|
|
244
|
+
own = node.parameters
|
|
245
|
+
from_path_item = item.parameters
|
|
246
|
+
declared = (own ? own.to_a : []) + (from_path_item ? from_path_item.to_a : [])
|
|
247
|
+
parameters = declared.uniq { |p| [p.name, p.in] }
|
|
248
|
+
.map { |p| build_parameter(p, hint: base) }
|
|
249
|
+
|
|
250
|
+
Model::Operation.new(
|
|
251
|
+
id: id,
|
|
252
|
+
http_method: Model::HttpMethod.deserialize(verb),
|
|
253
|
+
path: path,
|
|
254
|
+
tag: node.tags&.first || "default",
|
|
255
|
+
parameters: parameters,
|
|
256
|
+
request_body: body && build_request_body(body, hint: base),
|
|
257
|
+
responses: build_responses(node, hint: base),
|
|
258
|
+
security: requirements(node, declared: raw(node).key?("security")),
|
|
259
|
+
summary: node.summary,
|
|
260
|
+
description: node.description,
|
|
261
|
+
deprecated: !!node.deprecated?,
|
|
262
|
+
extensions: extensions(node)
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
sig { params(node: Openapi3Parser::Node::Parameter, hint: String).returns(Model::Parameter) }
|
|
267
|
+
def build_parameter(node, hint:)
|
|
268
|
+
info = Model::ParameterInfo.new(
|
|
269
|
+
name: node.name,
|
|
270
|
+
identifier: Naming.identifier(node.name),
|
|
271
|
+
schema: schema_for(node.schema, hint: "#{hint}#{Naming.pascal(node.name)}"),
|
|
272
|
+
description: node.description,
|
|
273
|
+
deprecated: !!node.deprecated?
|
|
274
|
+
)
|
|
275
|
+
explode = raw(node).key?("explode") ? !!node.explode? : nil
|
|
276
|
+
|
|
277
|
+
case node.in
|
|
278
|
+
when "path"
|
|
279
|
+
Model::PathParameter.new(info: info, style: path_style(node), explode: explode || false)
|
|
280
|
+
when "query"
|
|
281
|
+
Model::QueryParameter.new(info: info, required: !!node.required?, style: query_style(node),
|
|
282
|
+
explode: explode.nil? || explode,
|
|
283
|
+
allow_reserved: !!node.allow_reserved?)
|
|
284
|
+
when "header"
|
|
285
|
+
Model::HeaderParameter.new(info: info, required: !!node.required?, explode: explode || false)
|
|
286
|
+
when "cookie"
|
|
287
|
+
Model::CookieParameter.new(info: info, required: !!node.required?,
|
|
288
|
+
explode: explode.nil? || explode)
|
|
289
|
+
else
|
|
290
|
+
raise SchemaError, "Parameter #{node.name.inspect} has an unknown `in: #{node.in.inspect}`."
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
sig { params(node: Openapi3Parser::Node::Parameter).returns(Model::PathStyle) }
|
|
295
|
+
def path_style(node)
|
|
296
|
+
return Model::PathStyle::Simple if node.style.nil?
|
|
297
|
+
|
|
298
|
+
Model::PathStyle.try_deserialize(node.style) ||
|
|
299
|
+
raise(SchemaError,
|
|
300
|
+
"Path parameter #{node.name.inspect} has style #{node.style.inspect}. " \
|
|
301
|
+
"Path parameters support simple, label or matrix.")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
sig { params(node: Openapi3Parser::Node::Parameter).returns(Model::QueryStyle) }
|
|
305
|
+
def query_style(node)
|
|
306
|
+
return Model::QueryStyle::Form if node.style.nil?
|
|
307
|
+
|
|
308
|
+
Model::QueryStyle.try_deserialize(node.style) ||
|
|
309
|
+
raise(SchemaError,
|
|
310
|
+
"Query parameter #{node.name.inspect} has style #{node.style.inspect}. " \
|
|
311
|
+
"Query parameters support form, spaceDelimited, pipeDelimited or deepObject.")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
sig { params(node: Openapi3Parser::Node::RequestBody, hint: String).returns(Model::RequestBody) }
|
|
315
|
+
def build_request_body(node, hint:)
|
|
316
|
+
Model::RequestBody.new(
|
|
317
|
+
contents: contents(node, hint: "#{hint}Body", where: "the request body of #{hint}",
|
|
318
|
+
request: true),
|
|
319
|
+
required: !!node.required?,
|
|
320
|
+
description: node.description
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
sig { params(node: Openapi3Parser::Node::Operation, hint: String).returns(T::Array[Model::Response]) }
|
|
325
|
+
def build_responses(node, hint:)
|
|
326
|
+
responses = node.responses
|
|
327
|
+
return [] if responses.nil?
|
|
328
|
+
|
|
329
|
+
responses.map do |raw_status, response|
|
|
330
|
+
status = Model::Status.parse(raw_status.to_s)
|
|
331
|
+
scoped = "#{hint}#{Model::Status.constant(status)}"
|
|
332
|
+
Model::Response.new(
|
|
333
|
+
status: status,
|
|
334
|
+
contents: contents(
|
|
335
|
+
response, hint: scoped, where: "the #{raw_status} response of #{hint}"
|
|
336
|
+
),
|
|
337
|
+
headers: (response.headers || {}).map do |name, header|
|
|
338
|
+
Model::Header.new(name: name, identifier: Naming.identifier(name),
|
|
339
|
+
schema: schema_for(header.schema, hint: "#{scoped}#{Naming.pascal(name)}"),
|
|
340
|
+
required: !!header.required?, description: header.description)
|
|
341
|
+
end,
|
|
342
|
+
description: response.description
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
sig do
|
|
348
|
+
params(node: T.any(Openapi3Parser::Node::RequestBody, Openapi3Parser::Node::Response), hint: String,
|
|
349
|
+
where: String, request: T::Boolean).returns(T::Array[Model::Content])
|
|
350
|
+
end
|
|
351
|
+
def contents(node, hint:, where:, request: false)
|
|
352
|
+
content = node.content
|
|
353
|
+
return [] if content.nil?
|
|
354
|
+
|
|
355
|
+
multiple = content.keys.size > 1
|
|
356
|
+
content.map do |media_type, media|
|
|
357
|
+
suffix = multiple ? Naming.pascal(media_type.split("/").last.to_s.split("+").first.to_s) : ""
|
|
358
|
+
schema = (schema_for(media.schema, hint: "#{hint}#{suffix}") if media.schema)
|
|
359
|
+
reject_undecodable_media_type!(media_type, where: where, schema: schema)
|
|
360
|
+
reject_unspreadable_multipart!(schema: schema, where: where) if
|
|
361
|
+
request && Model::Content.multipart?(media_type)
|
|
362
|
+
Model::Content.new(media_type: media_type, schema: schema)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
sig { params(schema: T.nilable(Model::Schema), where: String).void }
|
|
367
|
+
def reject_unspreadable_multipart!(schema:, where:)
|
|
368
|
+
raise SchemaError, "#{multipart_prefix(where)} declares no schema. #{MULTIPART_SHAPE}" if schema.nil?
|
|
369
|
+
|
|
370
|
+
type = schema.is_a?(Model::Ref) ? type_named(schema.name) : nil
|
|
371
|
+
return if type.is_a?(Model::ObjectDef) || type.is_a?(Model::FormDef)
|
|
372
|
+
|
|
373
|
+
raise SchemaError, "#{multipart_prefix(where)} is not an object. #{MULTIPART_SHAPE}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
sig { params(where: String).returns(String) }
|
|
377
|
+
def multipart_prefix(where) = "The multipart/form-data content of #{where}"
|
|
378
|
+
|
|
379
|
+
sig { params(media_type: String, where: String, schema: T.nilable(Model::Schema)).void }
|
|
380
|
+
def reject_undecodable_media_type!(media_type, where:, schema:)
|
|
381
|
+
base = T.must(media_type.split(";").first).strip.downcase
|
|
382
|
+
return if DECODABLE_MEDIA_TYPES.include?(base) || base.end_with?("+json")
|
|
383
|
+
return if !schema.nil? && Model::Schema.file?(schema)
|
|
384
|
+
|
|
385
|
+
raise SchemaError,
|
|
386
|
+
"#{where} declares the content type #{media_type}, which openapi_kit cannot decode or " \
|
|
387
|
+
"render. Supported content types are #{DECODABLE_MEDIA_TYPES.join(", ")}, any " \
|
|
388
|
+
"+json media type, and any type at all for a response body whose schema is " \
|
|
389
|
+
"format: binary."
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
sig { params(document: Openapi3Parser::Document).returns(T::Array[Model::SecurityScheme]) }
|
|
393
|
+
def build_security_schemes(document)
|
|
394
|
+
(document.components&.security_schemes || {}).map do |name, node|
|
|
395
|
+
case node.type
|
|
396
|
+
when "apiKey"
|
|
397
|
+
Model::ApiKeyScheme.new(name: name, location: Model::ApiKeyLocation.deserialize(node.in),
|
|
398
|
+
parameter_name: node.name, description: node.description,
|
|
399
|
+
extensions: extensions(node))
|
|
400
|
+
when "http"
|
|
401
|
+
Model::HttpScheme.new(name: name, scheme: node.scheme.to_s.downcase,
|
|
402
|
+
bearer_format: node.bearer_format, description: node.description,
|
|
403
|
+
extensions: extensions(node))
|
|
404
|
+
when "oauth2"
|
|
405
|
+
Model::OAuth2Scheme.new(name: name, scopes: oauth_scopes(node, name: name), description: node.description,
|
|
406
|
+
extensions: extensions(node))
|
|
407
|
+
when "openIdConnect"
|
|
408
|
+
Model::OpenIDConnectScheme.new(name: name, url: node.open_id_connect_url.to_s,
|
|
409
|
+
description: node.description, extensions: extensions(node))
|
|
410
|
+
else
|
|
411
|
+
raise SchemaError, "Security scheme #{name.inspect} has unsupported type #{node.type.inspect}."
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
sig do
|
|
417
|
+
params(node: Openapi3Parser::Node::SecurityScheme, name: String)
|
|
418
|
+
.returns(T::Hash[String, String])
|
|
419
|
+
end
|
|
420
|
+
# A server checks scope names and nothing else: the flows' authorizationUrl,
|
|
421
|
+
# tokenUrl and refreshUrl tell a client where to get a token, so they are not
|
|
422
|
+
# carried. Scopes are unioned across the flows, and a scope described two ways
|
|
423
|
+
# warns rather than losing one description quietly.
|
|
424
|
+
def oauth_scopes(node, name:)
|
|
425
|
+
flows = node.flows
|
|
426
|
+
return {} if flows.nil?
|
|
427
|
+
|
|
428
|
+
OAUTH_FLOWS.each_with_object({}) do |flow_name, all|
|
|
429
|
+
flow = flows.public_send(flow_name)
|
|
430
|
+
(flow&.scopes || {}).each do |scope, description|
|
|
431
|
+
warn_scope_conflict(name, scope.to_s, all[scope.to_s], description.to_s)
|
|
432
|
+
all[scope.to_s] = description.to_s
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
sig do
|
|
438
|
+
params(scheme: String, scope: String, existing: T.nilable(String), description: String).void
|
|
439
|
+
end
|
|
440
|
+
def warn_scope_conflict(scheme, scope, existing, description)
|
|
441
|
+
return if existing.nil? || existing == description
|
|
442
|
+
|
|
443
|
+
@warnings << "The scope #{scope.inspect} of security scheme #{scheme.inspect} is " \
|
|
444
|
+
"described two ways across its OAuth flows: #{existing.inspect} and " \
|
|
445
|
+
"#{description.inspect}. openapi_kit keeps the last."
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
sig do
|
|
449
|
+
params(node: T.any(Openapi3Parser::Document, Openapi3Parser::Node::Operation),
|
|
450
|
+
declared: T::Boolean).returns(T.nilable(T::Array[Model::SecurityRequirement]))
|
|
451
|
+
end
|
|
452
|
+
def requirements(node, declared: true)
|
|
453
|
+
security = node.security
|
|
454
|
+
return nil if security.nil? || !declared
|
|
455
|
+
|
|
456
|
+
security.map do |requirement|
|
|
457
|
+
Model::SecurityRequirement.new(
|
|
458
|
+
schemes: requirement.to_h.transform_values { |scopes| Array(scopes).map(&:to_s) }
|
|
459
|
+
)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
sig do
|
|
464
|
+
params(node: T.nilable(Openapi3Parser::Node::Schema), hint: String, nullable: T::Boolean,
|
|
465
|
+
inherited: T.nilable(Model::Meta)).returns(Model::Schema)
|
|
466
|
+
end
|
|
467
|
+
def schema_for(node, hint:, nullable: false, inherited: nil)
|
|
468
|
+
return Model::Untyped.new if node.nil?
|
|
469
|
+
|
|
470
|
+
all_of = node.all_of
|
|
471
|
+
members = all_of ? all_of.to_a : []
|
|
472
|
+
properties = node.properties
|
|
473
|
+
if members.size == 1 && (properties.nil? || properties.empty?)
|
|
474
|
+
return schema_for(
|
|
475
|
+
members.first,
|
|
476
|
+
hint: hint,
|
|
477
|
+
nullable: nullable || !!node.nullable?,
|
|
478
|
+
inherited: merge_meta(inherited, meta_for(node, nullable: nullable))
|
|
479
|
+
)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
meta = merge_meta(inherited, meta_for(node, nullable: nullable))
|
|
483
|
+
declared = node.name
|
|
484
|
+
name =
|
|
485
|
+
if declared
|
|
486
|
+
rename(declared)
|
|
487
|
+
elsif named_shape?(node)
|
|
488
|
+
rename(hint)
|
|
489
|
+
end
|
|
490
|
+
if name
|
|
491
|
+
register(node, name: name)
|
|
492
|
+
return Model::Ref.new(name: name, meta: meta)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
structural(node, hint: hint, meta: meta)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
sig { params(over: T.nilable(Model::Meta), under: Model::Meta).returns(Model::Meta) }
|
|
499
|
+
def merge_meta(over, under)
|
|
500
|
+
return under if over.nil?
|
|
501
|
+
|
|
502
|
+
Model::Meta.new(
|
|
503
|
+
description: over.description || under.description,
|
|
504
|
+
nullable: over.nullable || under.nullable,
|
|
505
|
+
deprecated: over.deprecated || under.deprecated,
|
|
506
|
+
default: over.default || under.default,
|
|
507
|
+
read_only: over.read_only || under.read_only,
|
|
508
|
+
write_only: over.write_only || under.write_only,
|
|
509
|
+
extensions: under.extensions.merge(over.extensions),
|
|
510
|
+
ruby_type: over.ruby_type || under.ruby_type
|
|
511
|
+
)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
sig { params(node: Openapi3Parser::Node::Schema).returns(T::Boolean) }
|
|
515
|
+
def named_shape?(node)
|
|
516
|
+
return true if node.enum || node.one_of || node.any_of || node.all_of&.any?
|
|
517
|
+
|
|
518
|
+
properties = node.properties
|
|
519
|
+
!(properties.nil? || properties.empty?)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
sig { params(node: Openapi3Parser::Node::Schema, hint: String, meta: Model::Meta).returns(Model::Schema) }
|
|
523
|
+
def structural(node, hint:, meta:)
|
|
524
|
+
case node.type
|
|
525
|
+
when "string"
|
|
526
|
+
Model::StringSchema.new(format: node.format, min_length: node.min_length,
|
|
527
|
+
max_length: node.max_length, pattern: node.pattern, meta: meta)
|
|
528
|
+
when "integer"
|
|
529
|
+
Model::IntegerSchema.new(format: node.format, minimum: node.minimum, maximum: node.maximum,
|
|
530
|
+
exclusive_minimum: !!node.exclusive_minimum?,
|
|
531
|
+
exclusive_maximum: !!node.exclusive_maximum?,
|
|
532
|
+
multiple_of: node.multiple_of, meta: meta)
|
|
533
|
+
when "number"
|
|
534
|
+
Model::NumberSchema.new(format: node.format, minimum: node.minimum, maximum: node.maximum,
|
|
535
|
+
exclusive_minimum: !!node.exclusive_minimum?,
|
|
536
|
+
exclusive_maximum: !!node.exclusive_maximum?,
|
|
537
|
+
multiple_of: node.multiple_of, meta: meta)
|
|
538
|
+
when "boolean"
|
|
539
|
+
Model::BooleanSchema.new(meta: meta)
|
|
540
|
+
when "array"
|
|
541
|
+
Model::List.new(items: schema_for(node.items, hint: "#{hint}Item"), min_items: node.min_items,
|
|
542
|
+
max_items: node.max_items, unique_items: !!node.unique_items?, meta: meta)
|
|
543
|
+
when "object"
|
|
544
|
+
values = node.additional_properties_schema
|
|
545
|
+
Model::Freeform.new(values: (schema_for(values, hint: "#{hint}Value") if values),
|
|
546
|
+
min_properties: node.min_properties, max_properties: node.max_properties,
|
|
547
|
+
meta: meta)
|
|
548
|
+
else
|
|
549
|
+
Model::Untyped.new(meta: meta)
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
sig { params(node: Openapi3Parser::Node::Schema, name: String).void }
|
|
554
|
+
def register(node, name:)
|
|
555
|
+
key = type_key(node)
|
|
556
|
+
existing = @keys_by_name[name]
|
|
557
|
+
if existing && existing != key
|
|
558
|
+
raise SchemaError,
|
|
559
|
+
"Two different schemas both generate the name #{name}:\n #{existing}\n #{key}\n" \
|
|
560
|
+
"Disambiguate with name_overrides in your config."
|
|
561
|
+
end
|
|
562
|
+
return if @types.key?(key) || @in_progress.include?(key)
|
|
563
|
+
|
|
564
|
+
@in_progress << key
|
|
565
|
+
@keys_by_name[name] = key
|
|
566
|
+
begin
|
|
567
|
+
@types[key] = build_type_def(node, name: name)
|
|
568
|
+
ensure
|
|
569
|
+
@in_progress.delete(key)
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
sig { params(node: Openapi3Parser::Node::Schema, name: String).returns(Model::TypeDef) }
|
|
574
|
+
def build_type_def(node, name:)
|
|
575
|
+
return enum_def(node, name: name) if node.enum
|
|
576
|
+
return union_def(node, name: name) if node.one_of || node.any_of
|
|
577
|
+
return object_def(node, name: name) if node.all_of&.any? || node.properties&.any?
|
|
578
|
+
|
|
579
|
+
Model::AliasDef.new(name: name, target: structural(node, hint: name, meta: meta_for(node)),
|
|
580
|
+
meta: meta_for(node))
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
sig { params(node: Openapi3Parser::Node::Schema, name: String).returns(Model::TypeDef) }
|
|
584
|
+
def enum_def(node, name:)
|
|
585
|
+
values = node.enum.to_a
|
|
586
|
+
kinds = values.map(&:class).uniq
|
|
587
|
+
|
|
588
|
+
unless [[String], [Integer]].include?(kinds)
|
|
589
|
+
raise SchemaError,
|
|
590
|
+
"Enum #{name} has values of type #{kinds.map(&:name).sort.join(", ")}. " \
|
|
591
|
+
"A T::Enum needs its values to be all strings or all integers."
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
Model::EnumDef.new(
|
|
595
|
+
name: name,
|
|
596
|
+
members: values.map { |v| Model::EnumMember.new(constant: Naming.enum_member(v), value: v) },
|
|
597
|
+
meta: meta_for(node)
|
|
598
|
+
)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
sig { params(node: Openapi3Parser::Node::Schema, name: String).returns(Model::TypeDef) }
|
|
602
|
+
def union_def(node, name:)
|
|
603
|
+
declared = node.one_of || node.any_of
|
|
604
|
+
raw = declared ? declared.to_a : []
|
|
605
|
+
members = raw.each_with_index.map { |m, i| schema_for(m, hint: "#{name}Member#{i + 1}") }
|
|
606
|
+
|
|
607
|
+
Model::UnionDef.new(name: name, members: members, tag: union_tag(node),
|
|
608
|
+
meta: meta_for(node))
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
sig { params(node: Openapi3Parser::Node::Schema, name: String).returns(Model::TypeDef) }
|
|
612
|
+
def object_def(node, name:)
|
|
613
|
+
properties = T.let({}, T::Hash[String, Openapi3Parser::Node::Schema])
|
|
614
|
+
required = T.let(Set.new, T::Set[String])
|
|
615
|
+
collect_properties(node, properties, required)
|
|
616
|
+
|
|
617
|
+
fields = properties.map do |pname, pnode|
|
|
618
|
+
Model::Property.new(name: pname, identifier: Naming.identifier(pname),
|
|
619
|
+
schema: schema_for(pnode, hint: "#{name}#{Naming.pascal(pname)}"),
|
|
620
|
+
required: required.include?(pname))
|
|
621
|
+
end
|
|
622
|
+
kind = fields.any? { |field| Model::Schema.file?(field.schema) } ? Model::FormDef : Model::ObjectDef
|
|
623
|
+
|
|
624
|
+
kind.new(name: name, properties: fields,
|
|
625
|
+
additional_properties: additional_properties_for(node, name),
|
|
626
|
+
meta: meta_for(node))
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
sig { params(node: Openapi3Parser::Node::Schema, name: String).returns(T.nilable(Model::Schema)) }
|
|
630
|
+
def additional_properties_for(node, name)
|
|
631
|
+
schema = node.additional_properties_schema
|
|
632
|
+
return nil if schema.nil?
|
|
633
|
+
|
|
634
|
+
schema_for(schema, hint: "#{name}Value")
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
sig do
|
|
638
|
+
params(node: Openapi3Parser::Node::Schema, properties: T::Hash[String, Openapi3Parser::Node::Schema],
|
|
639
|
+
required: T::Set[String]).void
|
|
640
|
+
end
|
|
641
|
+
def collect_properties(node, properties, required)
|
|
642
|
+
all_of = node.all_of
|
|
643
|
+
all_of&.each { |member| collect_properties(member, properties, required) }
|
|
644
|
+
(node.properties || {}).each { |pname, pnode| properties[pname] = pnode }
|
|
645
|
+
node.required&.each { |name| required << name.to_s }
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
sig { params(node: Openapi3Parser::Node::Schema).returns(Model::UnionTag) }
|
|
649
|
+
def union_tag(node)
|
|
650
|
+
discriminator = node.discriminator
|
|
651
|
+
return Model::Untagged.new if discriminator.nil?
|
|
652
|
+
|
|
653
|
+
declared = discriminator.mapping
|
|
654
|
+
mapping = declared ? declared.to_h { |value, ref| [value.to_s, rename(ref.to_s.split("/").last.to_s)] } : {}
|
|
655
|
+
mapping = implicit_mapping(node) if mapping.empty?
|
|
656
|
+
|
|
657
|
+
Model::Tagged.new(property_name: discriminator.property_name, mapping: mapping)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
sig { params(node: Openapi3Parser::Node::Schema).returns(T::Hash[String, String]) }
|
|
661
|
+
def implicit_mapping(node)
|
|
662
|
+
declared = node.one_of || node.any_of
|
|
663
|
+
members = declared ? declared.to_a : []
|
|
664
|
+
members.to_h do |member|
|
|
665
|
+
name = member.name
|
|
666
|
+
if name.nil?
|
|
667
|
+
raise SchemaError,
|
|
668
|
+
"A member of the discriminated union #{node.name || "(inline)"} is an inline " \
|
|
669
|
+
"schema, so there is no name to map a discriminator value to. Move it into " \
|
|
670
|
+
"components/schemas, or declare an explicit discriminator mapping."
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
[name, rename(name)]
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
sig { params(node: Openapi3Parser::Node::Schema, nullable: T::Boolean).returns(Model::Meta) }
|
|
678
|
+
def meta_for(node, nullable: false)
|
|
679
|
+
data = raw(node)
|
|
680
|
+
Model::Meta.new(
|
|
681
|
+
description: node.description,
|
|
682
|
+
nullable: nullable || !!node.nullable?,
|
|
683
|
+
deprecated: !!node.deprecated?,
|
|
684
|
+
default: data.key?("default") ? Model::Default.new(value: node.default) : nil,
|
|
685
|
+
read_only: !!node.read_only?,
|
|
686
|
+
write_only: !!node.write_only?,
|
|
687
|
+
extensions: extensions(node),
|
|
688
|
+
ruby_type: ruby_type_for(data)
|
|
689
|
+
)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
sig { params(node: Openapi3Parser::Node::Schema).returns(String) }
|
|
693
|
+
def type_key(node)
|
|
694
|
+
location = node.node_context.source_location
|
|
695
|
+
"#{location.source.source_input.path}#{location.pointer}"
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
sig { params(data: T::Hash[String, T.untyped]).returns(T.nilable(RubyType)) }
|
|
699
|
+
def ruby_type_for(data)
|
|
700
|
+
type = data["x-ruby-type"]
|
|
701
|
+
codec = data["x-ruby-codec"]
|
|
702
|
+
return nil if type.nil? && codec.nil?
|
|
703
|
+
|
|
704
|
+
if type.nil? || codec.nil?
|
|
705
|
+
raise SchemaError,
|
|
706
|
+
"x-ruby-type and x-ruby-codec must be given together (found only " \
|
|
707
|
+
"#{type.nil? ? "x-ruby-codec" : "x-ruby-type"}). `x-ruby-type` is what appears in " \
|
|
708
|
+
"signatures, `x-ruby-codec` is what converts it: a module extending, or an " \
|
|
709
|
+
"instance of a class including, OpenAPIKit::Codec."
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
RubyType.new(type: type.to_s, codec: codec.to_s)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
sig { params(name: String).returns(String) }
|
|
716
|
+
def rename(name) = Naming.constant(@config.name_overrides.fetch(name, name))
|
|
717
|
+
|
|
718
|
+
sig { params(node: Openapi3Parser::Node::Object).returns(T::Hash[String, T.untyped]) }
|
|
719
|
+
def extensions(node)
|
|
720
|
+
raw(node).select { |k, _| k.to_s.start_with?("x-") }
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
sig { params(node: Openapi3Parser::Node::Object).returns(T::Hash[String, T.untyped]) }
|
|
724
|
+
def raw(node)
|
|
725
|
+
input = node.node_context.input
|
|
726
|
+
input.is_a?(Hash) ? input : {}
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
end
|