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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +385 -0
  4. data/exe/openapi_kit +6 -0
  5. data/lib/openapi_kit/codegen/cli.rb +72 -0
  6. data/lib/openapi_kit/codegen/config.rb +114 -0
  7. data/lib/openapi_kit/codegen/emit/buffer.rb +92 -0
  8. data/lib/openapi_kit/codegen/emit/codecs.rb +221 -0
  9. data/lib/openapi_kit/codegen/emit/controllers.rb +263 -0
  10. data/lib/openapi_kit/codegen/emit/decode.rb +43 -0
  11. data/lib/openapi_kit/codegen/emit/defaults.rb +42 -0
  12. data/lib/openapi_kit/codegen/emit/emitter.rb +17 -0
  13. data/lib/openapi_kit/codegen/emit/forms.rb +56 -0
  14. data/lib/openapi_kit/codegen/emit/handlers.rb +65 -0
  15. data/lib/openapi_kit/codegen/emit/literal.rb +28 -0
  16. data/lib/openapi_kit/codegen/emit/operations.rb +277 -0
  17. data/lib/openapi_kit/codegen/emit/registry.rb +101 -0
  18. data/lib/openapi_kit/codegen/emit/routes.rb +77 -0
  19. data/lib/openapi_kit/codegen/emit/security.rb +183 -0
  20. data/lib/openapi_kit/codegen/emit/source_file.rb +30 -0
  21. data/lib/openapi_kit/codegen/emit/types.rb +153 -0
  22. data/lib/openapi_kit/codegen/generator.rb +48 -0
  23. data/lib/openapi_kit/codegen/loader.rb +730 -0
  24. data/lib/openapi_kit/codegen/model/document.rb +302 -0
  25. data/lib/openapi_kit/codegen/model/schema.rb +110 -0
  26. data/lib/openapi_kit/codegen/model/type_def.rb +89 -0
  27. data/lib/openapi_kit/codegen/naming.rb +78 -0
  28. data/lib/openapi_kit/codegen/ruby_type.rb +48 -0
  29. data/lib/openapi_kit/codegen/type_registry.rb +325 -0
  30. data/lib/openapi_kit/codegen/writer.rb +92 -0
  31. data/lib/openapi_kit/codegen.rb +37 -0
  32. data/lib/openapi_kit-codegen.rb +4 -0
  33. 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