@kubb/adapter-oas 5.0.0-beta.6 → 5.0.0-beta.61

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.
package/src/parser.ts CHANGED
@@ -1,18 +1,20 @@
1
- import { URLPath } from '@internals/utils'
1
+ import { pascalCase } from '@internals/utils'
2
+ import { macroDiscriminatorEnum, macroEnumName, macroSimplifyUnion } from '@kubb/ast/macros'
3
+ import { childName, enumPropName, extractRefName, mergeAdjacentObjectsLazy } from '@kubb/ast/utils'
2
4
  import { ast } from '@kubb/core'
3
- import BaseOas from 'oas'
4
5
  import { DEFAULT_PARSER_OPTIONS, enumExtensionKeys, SCHEMA_REF_PREFIX, typeOptionMap } from './constants.ts'
5
- import { isDiscriminator, isNullable, isReference } from './guards.ts'
6
- import { resolveRef } from './refs.ts'
6
+ import { oasDialect, type OasDialect } from './dialect.ts'
7
+ import { createDiscriminantNode, findDiscriminator } from './discriminator.ts'
8
+ import { getOperationId, getOperations, getRequestContentType, getResponseByStatusCode, getResponseStatusCodes } from './operation.ts'
7
9
  import {
8
10
  buildSchemaNode,
9
11
  flattenSchema,
10
12
  getDateType,
11
- getMediaType,
12
13
  getParameters,
13
14
  getPrimitiveType,
14
15
  getRequestBodyContentTypes,
15
16
  getRequestSchema,
17
+ getResponseBodyContentTypes,
16
18
  getResponseSchema,
17
19
  getSchemas,
18
20
  getSchemaType,
@@ -30,6 +32,16 @@ export type OasParserContext = {
30
32
  contentType?: ContentType
31
33
  }
32
34
 
35
+ /**
36
+ * The object returned by {@link createSchemaParser}.
37
+ * Contains parser functions bound to a specific document.
38
+ */
39
+ export type SchemaParser = {
40
+ parseSchema: (entry: { schema: SchemaObject; name?: string | null }, options?: Partial<ast.ParserOptions>) => ast.SchemaNode
41
+ parseOperation: (options: ast.ParserOptions, operation: Operation) => ast.OperationNode
42
+ parseParameter: (options: ast.ParserOptions, param: Record<string, unknown>) => ast.ParameterNode
43
+ }
44
+
33
45
  /**
34
46
  * Pre-computed per-schema context passed to every schema converter.
35
47
  *
@@ -50,13 +62,33 @@ type SchemaContext = {
50
62
  options: ast.ParserOptions
51
63
  }
52
64
 
65
+ /**
66
+ * One entry in the ordered schema rule table: a predicate paired with a converter. A rule whose
67
+ * `match` returns `true` may still `convert` to `null` to defer to the next rule (e.g. a `format`
68
+ * that is not convertible falls through to plain `type` handling).
69
+ */
70
+ type SchemaRule = {
71
+ /**
72
+ * Identifies the rule when reading the table or debugging which branch ran.
73
+ */
74
+ name: string
75
+ /**
76
+ * Returns `true` when this rule is responsible for the given context.
77
+ */
78
+ match: (context: SchemaContext) => boolean
79
+ /**
80
+ * Produces a node for the context, or `null` to fall through to the next rule.
81
+ */
82
+ convert: (context: SchemaContext) => ast.SchemaNode | null
83
+ }
84
+
53
85
  /**
54
86
  * Normalizes malformed `{ type: 'array', enum: [...] }` schemas by moving enum values into items.
55
87
  *
56
88
  * This pattern violates the OpenAPI spec but appears in real specs. The fix moves enum values
57
- * from the array to its items sub-schema, making them valid for downstream processing.
89
+ * from the array to its items sub-schema, so they are valid for downstream processing.
58
90
  *
59
- * @note This is a defensive measure for robustness with non-compliant specs.
91
+ * @note A defensive measure for non-compliant specs.
60
92
  */
61
93
  function normalizeArrayEnum(schema: SchemaObject): SchemaObject {
62
94
  const isItemsObject = typeof schema.items === 'object' && !Array.isArray(schema.items)
@@ -69,19 +101,36 @@ function normalizeArrayEnum(schema: SchemaObject): SchemaObject {
69
101
  return { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject
70
102
  }
71
103
 
104
+ /**
105
+ * Names the inline enums on a property's schema, and on each item when the property is a tuple, from
106
+ * the parent and property name. Wraps `macroEnumName` at the property construction site.
107
+ */
108
+ function nameEnums(node: ast.SchemaNode, options: { parentName: string | null | undefined; propName: string; enumSuffix: string }): ast.SchemaNode {
109
+ const macro = macroEnumName(options)
110
+ const named = ast.applyMacros(node, [macro], { depth: 'shallow' })
111
+ const tupleNode = ast.narrowSchema(named, 'tuple')
112
+ if (tupleNode?.items) {
113
+ const namedItems = tupleNode.items.map((item) => ast.applyMacros(item, [macro], { depth: 'shallow' }))
114
+ if (namedItems.some((item, i) => item !== tupleNode.items![i])) {
115
+ return { ...tupleNode, items: namedItems }
116
+ }
117
+ }
118
+ return named
119
+ }
120
+
72
121
  /**
73
122
  * Factory function that creates schema and operation converters for a given OpenAPI context.
74
123
  *
75
124
  * Returns closures that share mutable state (`resolvingRefs` set for cycle detection).
76
125
  * Each converter branch (`convertRef`, `convertAllOf`, etc.) mutually recursively calls `parseSchema`,
77
- * made possible by hoisting of function declarations.
126
+ * which works because function declarations hoist.
78
127
  *
79
- * @note Not exported; called internally by `parseOas()` and `parseSchema()`.
128
+ * @internal
80
129
  */
81
- function createSchemaParser(ctx: OasParserContext) {
130
+ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect = oasDialect) {
82
131
  const document = ctx.document
83
132
 
84
- // Branch handlers each converts one OAS schema pattern to a SchemaNode.
133
+ // Branch handlers, each converts one OAS schema pattern to a SchemaNode.
85
134
 
86
135
  /**
87
136
  * Tracks `$ref` paths that are currently being resolved to prevent infinite
@@ -89,6 +138,16 @@ function createSchemaParser(ctx: OasParserContext) {
89
138
  */
90
139
  const resolvingRefs = new Set<string>()
91
140
 
141
+ /**
142
+ * Cache of `$ref` schemas already resolved in this parser instance, keyed by ref path.
143
+ *
144
+ * Without it, a shared schema (e.g. `customer`) is re-expanded for every `$ref` that points at
145
+ * it. In cross-referenced specs like Stripe (~1400 schemas) that becomes exponential blowup,
146
+ * since one schema can be referenced from dozens of parents, each re-walking its whole subtree.
147
+ * Memoizing by ref path drops the work from O(2^depth) to O(N) unique schema names.
148
+ */
149
+ const resolvedRefCache = new Map<string, ast.SchemaNode | null>()
150
+
92
151
  /**
93
152
  * Converts a `$ref` schema into a `RefSchemaNode`.
94
153
  *
@@ -98,25 +157,29 @@ function createSchemaParser(ctx: OasParserContext) {
98
157
  * Circular refs are detected via `resolvingRefs` and leave `schema` as `undefined`.
99
158
  */
100
159
  function convertRef({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
101
- let resolvedSchema: ast.SchemaNode | undefined
160
+ let resolvedSchema: ast.SchemaNode | null = null
102
161
  const refPath = schema.$ref
103
162
  if (refPath && !resolvingRefs.has(refPath)) {
104
- try {
105
- const referenced = resolveRef<SchemaObject>(document, refPath)
106
- if (referenced) {
107
- resolvingRefs.add(refPath)
108
- resolvedSchema = parseSchema({ schema: referenced }, rawOptions)
109
- resolvingRefs.delete(refPath)
163
+ if (!resolvedRefCache.has(refPath)) {
164
+ try {
165
+ const referenced = dialect.resolveRef<SchemaObject>(document, refPath)
166
+ if (referenced) {
167
+ resolvingRefs.add(refPath)
168
+ resolvedSchema = parseSchema({ schema: referenced }, rawOptions)
169
+ resolvingRefs.delete(refPath)
170
+ }
171
+ } catch {
172
+ // Ref cannot be resolved in this document (e.g. unit tests with minimal documents).
110
173
  }
111
- } catch {
112
- // Ref cannot be resolved in this document (e.g. unit tests with minimal documents).
174
+ resolvedRefCache.set(refPath, resolvedSchema)
113
175
  }
176
+ resolvedSchema = resolvedRefCache.get(refPath) ?? null
114
177
  }
115
178
 
116
- return ast.createSchema({
179
+ return ast.factory.createSchema({
117
180
  ...buildSchemaNode(schema, name, nullable, defaultValue),
118
181
  type: 'ref',
119
- name: ast.extractRefName(schema.$ref!),
182
+ name: extractRefName(schema.$ref!),
120
183
  ref: schema.$ref,
121
184
  schema: resolvedSchema,
122
185
  })
@@ -133,12 +196,12 @@ function createSchemaParser(ctx: OasParserContext) {
133
196
  schema.additionalProperties === undefined
134
197
  ) {
135
198
  const [memberSchema] = schema.allOf as Array<SchemaObject | ReferenceObject>
136
- const memberNode = parseSchema({ schema: memberSchema! as SchemaObject, name: null }, rawOptions)
199
+ const memberNode = parseSchema({ schema: memberSchema! as SchemaObject, name }, rawOptions)
137
200
  const { kind: _kind, ...memberNodeProps } = memberNode
138
201
  const mergedNullable = nullable || memberNode.nullable || undefined
139
202
  const mergedDefault = schema.default === null && mergedNullable ? undefined : (schema.default ?? memberNode.default)
140
203
 
141
- return ast.createSchema({
204
+ return ast.factory.createSchema({
142
205
  ...memberNodeProps,
143
206
  name,
144
207
  title: schema.title ?? memberNode.title,
@@ -150,6 +213,7 @@ function createSchemaParser(ctx: OasParserContext) {
150
213
  default: mergedDefault,
151
214
  example: schema.example ?? memberNode.example,
152
215
  pattern: schema.pattern ?? ('pattern' in memberNode ? memberNode.pattern : undefined),
216
+ format: schema.format ?? memberNode.format,
153
217
  } as ast.DistributiveOmit<ast.SchemaNode, 'kind'>)
154
218
  }
155
219
 
@@ -159,16 +223,16 @@ function createSchemaParser(ctx: OasParserContext) {
159
223
  }> = []
160
224
  const allOfMembers: Array<ast.SchemaNode> = (schema.allOf as Array<SchemaObject | ReferenceObject>)
161
225
  .filter((item) => {
162
- if (!isReference(item) || !name) return true
163
- const deref = resolveRef<SchemaObject>(document, item.$ref)
164
- if (!deref || !isDiscriminator(deref)) return true
226
+ if (!dialect.isReference(item) || !name) return true
227
+ const deref = dialect.resolveRef<SchemaObject>(document, item.$ref)
228
+ if (!deref || !dialect.isDiscriminator(deref)) return true
165
229
  const parentUnion = deref.oneOf ?? deref.anyOf
166
230
  if (!parentUnion) return true
167
231
  const childRef = `${SCHEMA_REF_PREFIX}${name}`
168
- const inOneOf = parentUnion.some((oneOfItem) => isReference(oneOfItem) && oneOfItem.$ref === childRef)
232
+ const inOneOf = parentUnion.some((oneOfItem) => dialect.isReference(oneOfItem) && oneOfItem.$ref === childRef)
169
233
  const inMapping = Object.values(deref.discriminator.mapping ?? {}).some((v) => v === childRef)
170
234
  if (inOneOf || inMapping) {
171
- const discriminatorValue = ast.findDiscriminator(deref.discriminator.mapping, childRef)
235
+ const discriminatorValue = findDiscriminator(deref.discriminator.mapping, childRef)
172
236
  if (discriminatorValue) {
173
237
  filteredDiscriminantValues.push({
174
238
  propertyName: deref.discriminator.propertyName,
@@ -179,7 +243,7 @@ function createSchemaParser(ctx: OasParserContext) {
179
243
  }
180
244
  return true
181
245
  })
182
- .map((s) => parseSchema({ schema: s as SchemaObject }, rawOptions))
246
+ .map((s) => parseSchema({ schema: s as SchemaObject, name }, rawOptions))
183
247
 
184
248
  const syntheticStart = allOfMembers.length
185
249
 
@@ -189,9 +253,9 @@ function createSchemaParser(ctx: OasParserContext) {
189
253
 
190
254
  if (missingRequired.length) {
191
255
  const resolvedMembers = (schema.allOf as Array<SchemaObject | ReferenceObject>).flatMap((item) => {
192
- if (!isReference(item)) return [item as SchemaObject]
193
- const deref = resolveRef<SchemaObject>(document, item.$ref)
194
- return deref && !isReference(deref) ? [deref] : []
256
+ if (!dialect.isReference(item)) return [item as SchemaObject]
257
+ const deref = dialect.resolveRef<SchemaObject>(document, item.$ref)
258
+ return deref && !dialect.isReference(deref) ? [deref] : []
195
259
  })
196
260
 
197
261
  for (const key of missingRequired) {
@@ -204,6 +268,7 @@ function createSchemaParser(ctx: OasParserContext) {
204
268
  properties: { [key]: resolved.properties[key] },
205
269
  required: [key],
206
270
  } as SchemaObject,
271
+ name,
207
272
  },
208
273
  rawOptions,
209
274
  ),
@@ -217,16 +282,19 @@ function createSchemaParser(ctx: OasParserContext) {
217
282
 
218
283
  if (schema.properties) {
219
284
  const { allOf: _allOf, ...schemaWithoutAllOf } = schema
285
+ // Don't pass `name` here, the result must stay anonymous so it can be merged with the
286
+ // adjacent synthetic object in `mergeAdjacentObjectsLazy`. Nested enum qualification
287
+ // happens upstream via `convertObject`'s `setEnumName` propagation.
220
288
  allOfMembers.push(parseSchema({ schema: schemaWithoutAllOf }, rawOptions))
221
289
  }
222
290
 
223
291
  for (const { propertyName, value } of filteredDiscriminantValues) {
224
- allOfMembers.push(ast.createDiscriminantNode({ propertyName, value }))
292
+ allOfMembers.push(createDiscriminantNode({ propertyName, value }))
225
293
  }
226
294
 
227
- return ast.createSchema({
295
+ return ast.factory.createSchema({
228
296
  type: 'intersection',
229
- members: [...ast.mergeAdjacentObjects(allOfMembers.slice(0, syntheticStart)), ...ast.mergeAdjacentObjects(allOfMembers.slice(syntheticStart))],
297
+ members: [...mergeAdjacentObjectsLazy(allOfMembers.slice(0, syntheticStart)), ...mergeAdjacentObjectsLazy(allOfMembers.slice(syntheticStart))],
230
298
  ...buildSchemaNode(schema, name, nullable, defaultValue),
231
299
  })
232
300
  }
@@ -243,7 +311,7 @@ function createSchemaParser(ctx: OasParserContext) {
243
311
  return null
244
312
  }
245
313
 
246
- return ast.createSchema({
314
+ return ast.factory.createSchema({
247
315
  type: 'object',
248
316
  primitive: 'object',
249
317
  properties: [discriminatorProperty],
@@ -254,10 +322,10 @@ function createSchemaParser(ctx: OasParserContext) {
254
322
  const strategy: 'one' | 'any' = schema.oneOf ? 'one' : 'any'
255
323
  const unionBase = {
256
324
  ...buildSchemaNode(schema, name, nullable, defaultValue),
257
- discriminatorPropertyName: isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
325
+ discriminatorPropertyName: dialect.isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
258
326
  strategy,
259
327
  }
260
- const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
328
+ const discriminator = dialect.isDiscriminator(schema) ? schema.discriminator : undefined
261
329
  const sharedPropertiesNode = schema.properties
262
330
  ? (() => {
263
331
  const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
@@ -270,9 +338,9 @@ function createSchemaParser(ctx: OasParserContext) {
270
338
 
271
339
  if (sharedPropertiesNode || discriminator?.mapping) {
272
340
  const members = unionMembers.map((s) => {
273
- const ref = isReference(s) ? s.$ref : undefined
274
- const discriminatorValue = ast.findDiscriminator(discriminator?.mapping, ref)
275
- const memberNode = parseSchema({ schema: s as SchemaObject }, rawOptions)
341
+ const ref = dialect.isReference(s) ? s.$ref : undefined
342
+ const discriminatorValue = findDiscriminator(discriminator?.mapping, ref)
343
+ const memberNode = parseSchema({ schema: s as SchemaObject, name }, rawOptions)
276
344
 
277
345
  if (!discriminatorValue || !discriminator) {
278
346
  return memberNode
@@ -280,21 +348,19 @@ function createSchemaParser(ctx: OasParserContext) {
280
348
 
281
349
  const narrowedDiscriminatorNode = sharedPropertiesNode
282
350
  ? pickDiscriminatorPropertyNode(
283
- ast.setDiscriminatorEnum({
284
- node: sharedPropertiesNode,
285
- propertyName: discriminator.propertyName,
286
- values: [discriminatorValue],
351
+ ast.applyMacros(sharedPropertiesNode, [macroDiscriminatorEnum({ propertyName: discriminator.propertyName, values: [discriminatorValue] })], {
352
+ depth: 'shallow',
287
353
  }),
288
354
  discriminator.propertyName,
289
355
  )
290
356
  : undefined
291
357
 
292
- return ast.createSchema({
358
+ return ast.factory.createSchema({
293
359
  type: 'intersection',
294
360
  members: [
295
361
  memberNode,
296
362
  narrowedDiscriminatorNode ??
297
- ast.createDiscriminantNode({
363
+ createDiscriminantNode({
298
364
  propertyName: discriminator.propertyName,
299
365
  value: discriminatorValue,
300
366
  }),
@@ -302,7 +368,7 @@ function createSchemaParser(ctx: OasParserContext) {
302
368
  })
303
369
  })
304
370
 
305
- const unionNode = ast.createSchema({
371
+ const unionNode = ast.factory.createSchema({
306
372
  type: 'union',
307
373
  ...unionBase,
308
374
  members,
@@ -312,18 +378,20 @@ function createSchemaParser(ctx: OasParserContext) {
312
378
  return unionNode
313
379
  }
314
380
 
315
- return ast.createSchema({
381
+ return ast.factory.createSchema({
316
382
  type: 'intersection',
317
383
  ...buildSchemaNode(schema, name, nullable, defaultValue),
318
384
  members: [unionNode, sharedPropertiesNode],
319
385
  })
320
386
  }
321
387
 
322
- return ast.createSchema({
388
+ const unionNode = ast.factory.createSchema({
323
389
  type: 'union',
324
390
  ...unionBase,
325
- members: ast.simplifyUnion(unionMembers.map((s) => parseSchema({ schema: s as SchemaObject }, rawOptions))),
391
+ members: unionMembers.map((s) => parseSchema({ schema: s as SchemaObject, name }, rawOptions)),
326
392
  })
393
+
394
+ return ast.applyMacros(unionNode, [macroSimplifyUnion], { depth: 'shallow' })
327
395
  }
328
396
 
329
397
  /**
@@ -333,18 +401,19 @@ function createSchemaParser(ctx: OasParserContext) {
333
401
  const constValue = schema.const
334
402
 
335
403
  if (constValue === null) {
336
- return ast.createSchema({
404
+ return ast.factory.createSchema({
337
405
  type: 'null',
338
406
  primitive: 'null',
339
407
  name,
340
408
  title: schema.title,
341
409
  description: schema.description,
342
410
  deprecated: schema.deprecated,
411
+ format: schema.format,
343
412
  })
344
413
  }
345
414
 
346
415
  const constPrimitive = getPrimitiveType(typeof constValue === 'number' ? 'number' : typeof constValue === 'boolean' ? 'boolean' : 'string')
347
- return ast.createSchema({
416
+ return ast.factory.createSchema({
348
417
  type: 'enum',
349
418
  primitive: constPrimitive,
350
419
  enumValues: [constValue as string | number | boolean],
@@ -360,7 +429,7 @@ function createSchemaParser(ctx: OasParserContext) {
360
429
  const base = buildSchemaNode(schema, name, nullable, defaultValue)
361
430
 
362
431
  if (schema.format === 'int64') {
363
- return ast.createSchema({
432
+ return ast.factory.createSchema({
364
433
  type: options.integerType === 'bigint' ? 'bigint' : 'integer',
365
434
  primitive: 'integer',
366
435
  ...base,
@@ -376,7 +445,7 @@ function createSchemaParser(ctx: OasParserContext) {
376
445
  if (!dateType) return null
377
446
 
378
447
  if (dateType.type === 'datetime') {
379
- return ast.createSchema({
448
+ return ast.factory.createSchema({
380
449
  ...base,
381
450
  primitive: 'string' as const,
382
451
  type: 'datetime',
@@ -384,7 +453,7 @@ function createSchemaParser(ctx: OasParserContext) {
384
453
  local: dateType.local,
385
454
  })
386
455
  }
387
- return ast.createSchema({
456
+ return ast.factory.createSchema({
388
457
  ...base,
389
458
  primitive: 'string' as const,
390
459
  type: dateType.type,
@@ -398,14 +467,14 @@ function createSchemaParser(ctx: OasParserContext) {
398
467
  const specialPrimitive: ast.PrimitiveSchemaType = specialType === 'number' || specialType === 'integer' || specialType === 'bigint' ? specialType : 'string'
399
468
 
400
469
  if (specialType === 'number' || specialType === 'integer' || specialType === 'bigint') {
401
- return ast.createSchema({
470
+ return ast.factory.createSchema({
402
471
  ...base,
403
472
  primitive: specialPrimitive,
404
473
  type: specialType,
405
474
  })
406
475
  }
407
476
  if (specialType === 'url') {
408
- return ast.createSchema({
477
+ return ast.factory.createSchema({
409
478
  ...base,
410
479
  primitive: 'string' as const,
411
480
  type: 'url',
@@ -414,21 +483,21 @@ function createSchemaParser(ctx: OasParserContext) {
414
483
  })
415
484
  }
416
485
  if (specialType === 'ipv4') {
417
- return ast.createSchema({
486
+ return ast.factory.createSchema({
418
487
  ...base,
419
488
  primitive: 'string' as const,
420
489
  type: 'ipv4',
421
490
  })
422
491
  }
423
492
  if (specialType === 'ipv6') {
424
- return ast.createSchema({
493
+ return ast.factory.createSchema({
425
494
  ...base,
426
495
  primitive: 'string' as const,
427
496
  type: 'ipv6',
428
497
  })
429
498
  }
430
499
  if (specialType === 'uuid' || specialType === 'email') {
431
- return ast.createSchema({
500
+ return ast.factory.createSchema({
432
501
  ...base,
433
502
  primitive: 'string' as const,
434
503
  type: specialType,
@@ -437,7 +506,7 @@ function createSchemaParser(ctx: OasParserContext) {
437
506
  })
438
507
  }
439
508
 
440
- return ast.createSchema({
509
+ return ast.factory.createSchema({
441
510
  ...base,
442
511
  primitive: specialPrimitive,
443
512
  type: specialType as ast.ScalarSchemaType,
@@ -454,6 +523,22 @@ function createSchemaParser(ctx: OasParserContext) {
454
523
 
455
524
  const nullInEnum = schema.enum!.includes(null)
456
525
  const filteredValues = (nullInEnum ? schema.enum!.filter((v) => v !== null) : schema.enum!) as Array<string | number | boolean>
526
+
527
+ // drf-spectacular `NullEnum` ({ enum: [null] }) is just `null`. An empty enum node would
528
+ // render as `never` (plugin-ts) / invalid `z.enum([])` (plugin-zod). Mirror the `const: null`
529
+ // branch so it renders as a clean `null` (not `z.null().nullable()`).
530
+ if (nullInEnum && filteredValues.length === 0) {
531
+ return ast.factory.createSchema({
532
+ type: 'null',
533
+ primitive: 'null',
534
+ name,
535
+ title: schema.title,
536
+ description: schema.description,
537
+ deprecated: schema.deprecated,
538
+ format: schema.format,
539
+ })
540
+ }
541
+
457
542
  const enumNullable = nullable || nullInEnum || undefined
458
543
  const enumDefault = schema.default === null && enumNullable ? undefined : schema.default
459
544
  const enumPrimitive = getPrimitiveType(type)
@@ -470,6 +555,7 @@ function createSchemaParser(ctx: OasParserContext) {
470
555
  writeOnly: schema.writeOnly,
471
556
  default: enumDefault,
472
557
  example: schema.example,
558
+ format: schema.format,
473
559
  }
474
560
 
475
561
  const extensionKey = enumExtensionKeys.find((key) => key in schema)
@@ -482,7 +568,7 @@ function createSchemaParser(ctx: OasParserContext) {
482
568
  const uniqueValues = [...new Set(filteredValues)]
483
569
  const seenNames = new Set<string>()
484
570
 
485
- return ast.createSchema({
571
+ return ast.factory.createSchema({
486
572
  ...enumBase,
487
573
  primitive: enumPrimitiveType,
488
574
  namedEnumValues: uniqueValues
@@ -499,7 +585,7 @@ function createSchemaParser(ctx: OasParserContext) {
499
585
  })
500
586
  }
501
587
 
502
- return ast.createSchema({
588
+ return ast.factory.createSchema({
503
589
  ...enumBase,
504
590
  enumValues: [...new Set(filteredValues)],
505
591
  })
@@ -513,21 +599,13 @@ function createSchemaParser(ctx: OasParserContext) {
513
599
  ? Object.entries(schema.properties).map(([propName, propSchema]) => {
514
600
  const required = Array.isArray(schema.required) ? schema.required.includes(propName) : !!schema.required
515
601
  const resolvedPropSchema = propSchema as SchemaObject
516
- const propNullable = isNullable(resolvedPropSchema)
602
+ const propNullable = dialect.isNullable(resolvedPropSchema)
517
603
 
518
- const resolvedChildName = ast.childName(name, propName)
604
+ const resolvedChildName = childName(name, propName)
519
605
  const propNode = parseSchema({ schema: resolvedPropSchema, name: resolvedChildName }, rawOptions)
520
- let schemaNode = ast.setEnumName(propNode, name, propName, options.enumSuffix)
521
-
522
- const tupleNode = ast.narrowSchema(schemaNode, 'tuple')
523
- if (tupleNode?.items) {
524
- const namedItems = tupleNode.items.map((item) => ast.setEnumName(item, name, propName, options.enumSuffix))
525
- if (namedItems.some((item, i) => item !== tupleNode.items![i])) {
526
- schemaNode = { ...tupleNode, items: namedItems }
527
- }
528
- }
606
+ const schemaNode = nameEnums(propNode, { parentName: name, propName, enumSuffix: options.enumSuffix })
529
607
 
530
- return ast.createProperty({
608
+ return ast.factory.createProperty({
531
609
  name: propName,
532
610
  schema: {
533
611
  ...schemaNode,
@@ -539,18 +617,15 @@ function createSchemaParser(ctx: OasParserContext) {
539
617
  : []
540
618
 
541
619
  const additionalProperties = schema.additionalProperties
542
- let additionalPropertiesNode: ast.SchemaNode | boolean | undefined
543
- if (additionalProperties === true) {
544
- additionalPropertiesNode = true
545
- } else if (additionalProperties && Object.keys(additionalProperties).length > 0) {
546
- additionalPropertiesNode = parseSchema({ schema: additionalProperties as SchemaObject }, rawOptions)
547
- } else if (additionalProperties === false) {
548
- additionalPropertiesNode = false
549
- } else if (additionalProperties) {
550
- additionalPropertiesNode = ast.createSchema({
551
- type: typeOptionMap.get(options.unknownType)!,
552
- })
553
- }
620
+ const additionalPropertiesNode: ast.SchemaNode | boolean | undefined = (() => {
621
+ if (additionalProperties === true) return true
622
+ if (additionalProperties === false) return false
623
+ if (additionalProperties && Object.keys(additionalProperties).length > 0) {
624
+ return parseSchema({ schema: additionalProperties as SchemaObject }, rawOptions)
625
+ }
626
+ if (additionalProperties) return ast.factory.createSchema({ type: typeOptionMap.get(options.unknownType)! })
627
+ return undefined
628
+ })()
554
629
 
555
630
  const rawPatternProperties = 'patternProperties' in schema ? schema.patternProperties : undefined
556
631
 
@@ -559,7 +634,7 @@ function createSchemaParser(ctx: OasParserContext) {
559
634
  Object.entries(rawPatternProperties).map(([pattern, patternSchema]) => [
560
635
  pattern,
561
636
  patternSchema === true || (typeof patternSchema === 'object' && Object.keys(patternSchema).length === 0)
562
- ? ast.createSchema({
637
+ ? ast.factory.createSchema({
563
638
  type: typeOptionMap.get(options.unknownType)!,
564
639
  })
565
640
  : parseSchema({ schema: patternSchema as SchemaObject }, rawOptions),
@@ -567,7 +642,7 @@ function createSchemaParser(ctx: OasParserContext) {
567
642
  )
568
643
  : undefined
569
644
 
570
- const objectNode: ast.SchemaNode = ast.createSchema({
645
+ const objectNode: ast.SchemaNode = ast.factory.createSchema({
571
646
  type: 'object',
572
647
  primitive: 'object',
573
648
  properties,
@@ -578,16 +653,11 @@ function createSchemaParser(ctx: OasParserContext) {
578
653
  ...buildSchemaNode(schema, name, nullable, defaultValue),
579
654
  })
580
655
 
581
- if (isDiscriminator(schema) && schema.discriminator.mapping) {
656
+ if (dialect.isDiscriminator(schema) && schema.discriminator.mapping) {
582
657
  const discPropName = schema.discriminator.propertyName
583
658
  const values = Object.keys(schema.discriminator.mapping)
584
- const enumName = name ? ast.enumPropName(name, discPropName, options.enumSuffix) : undefined
585
- return ast.setDiscriminatorEnum({
586
- node: objectNode,
587
- propertyName: discPropName,
588
- values,
589
- enumName,
590
- })
659
+ const enumName = name ? enumPropName(name, discPropName, options.enumSuffix) : undefined
660
+ return ast.applyMacros(objectNode, [macroDiscriminatorEnum({ propertyName: discPropName, values, enumName })], { depth: 'shallow' })
591
661
  }
592
662
 
593
663
  return objectNode
@@ -598,9 +668,9 @@ function createSchemaParser(ctx: OasParserContext) {
598
668
  */
599
669
  function convertTuple({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
600
670
  const tupleItems = (schema.prefixItems ?? []).map((item) => parseSchema({ schema: item as SchemaObject }, rawOptions))
601
- const rest = schema.items ? parseSchema({ schema: schema.items as SchemaObject }, rawOptions) : ast.createSchema({ type: 'any' })
671
+ const rest = schema.items ? parseSchema({ schema: schema.items as SchemaObject }, rawOptions) : ast.factory.createSchema({ type: 'any' })
602
672
 
603
- return ast.createSchema({
673
+ return ast.factory.createSchema({
604
674
  type: 'tuple',
605
675
  primitive: 'array',
606
676
  items: tupleItems,
@@ -616,10 +686,10 @@ function createSchemaParser(ctx: OasParserContext) {
616
686
  */
617
687
  function convertArray({ schema, name, nullable, defaultValue, rawOptions, options }: SchemaContext): ast.SchemaNode {
618
688
  const rawItems = schema.items as SchemaObject | undefined
619
- const itemName = rawItems?.enum?.length && name ? ast.enumPropName(undefined, name, options.enumSuffix) : undefined
689
+ const itemName = rawItems?.enum?.length && name ? enumPropName(null, name, options.enumSuffix) : name
620
690
  const items = rawItems ? [parseSchema({ schema: rawItems, name: itemName }, rawOptions)] : []
621
691
 
622
- return ast.createSchema({
692
+ return ast.factory.createSchema({
623
693
  type: 'array',
624
694
  primitive: 'array',
625
695
  items,
@@ -634,7 +704,7 @@ function createSchemaParser(ctx: OasParserContext) {
634
704
  * Converts a `type: 'string'` schema into a `StringSchemaNode`.
635
705
  */
636
706
  function convertString({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
637
- return ast.createSchema({
707
+ return ast.factory.createSchema({
638
708
  type: 'string',
639
709
  primitive: 'string',
640
710
  min: schema.minLength,
@@ -648,7 +718,7 @@ function createSchemaParser(ctx: OasParserContext) {
648
718
  * Converts a `type: 'number'` or `type: 'integer'` schema.
649
719
  */
650
720
  function convertNumeric({ schema, name, nullable, defaultValue }: SchemaContext, type: 'number' | 'integer'): ast.SchemaNode {
651
- return ast.createSchema({
721
+ return ast.factory.createSchema({
652
722
  type,
653
723
  primitive: type,
654
724
  min: schema.minimum,
@@ -664,7 +734,7 @@ function createSchemaParser(ctx: OasParserContext) {
664
734
  * Converts a `type: 'boolean'` schema.
665
735
  */
666
736
  function convertBoolean({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
667
- return ast.createSchema({
737
+ return ast.factory.createSchema({
668
738
  type: 'boolean',
669
739
  primitive: 'boolean',
670
740
  ...buildSchemaNode(schema, name, nullable, defaultValue),
@@ -675,7 +745,7 @@ function createSchemaParser(ctx: OasParserContext) {
675
745
  * Converts an explicit `type: 'null'` schema.
676
746
  */
677
747
  function convertNull({ schema, name, nullable }: SchemaContext): ast.SchemaNode {
678
- return ast.createSchema({
748
+ return ast.factory.createSchema({
679
749
  type: 'null',
680
750
  primitive: 'null',
681
751
  name,
@@ -683,15 +753,90 @@ function createSchemaParser(ctx: OasParserContext) {
683
753
  description: schema.description,
684
754
  deprecated: schema.deprecated,
685
755
  nullable,
756
+ format: schema.format,
686
757
  })
687
758
  }
688
759
 
689
760
  /**
690
- * Central dispatcher that converts an OAS `SchemaObject` into a `SchemaNode`.
761
+ * Converts a binary string schema (`type: 'string'`, `contentMediaType: 'application/octet-stream'`)
762
+ * into a `blob` node.
763
+ */
764
+ function convertBlob({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
765
+ return ast.factory.createSchema({
766
+ type: 'blob',
767
+ primitive: 'string',
768
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
769
+ })
770
+ }
771
+
772
+ /**
773
+ * Converts an OAS 3.1 multi-type array (e.g. `type: ['string', 'number']`) into a `UnionSchemaNode`.
691
774
  *
692
- * Dispatch order (first match wins): `$ref` `allOf` `oneOf`/`anyOf` `const` `format`
693
- * octet-stream blob multi-type array constraint-inferred type `enum` object/array/tuple/scalar
694
- * → empty-schema fallback (`emptySchemaType` option).
775
+ * Returns `null` when only one non-`null` type remains (e.g. `['string', 'null']`), so `parseSchema`
776
+ * falls through and handles it as that single type with nullability already folded in.
777
+ */
778
+ function convertMultiType({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode | null {
779
+ const types = schema.type as Array<string>
780
+ const nonNullTypes = types.filter((t) => t !== 'null')
781
+ if (nonNullTypes.length <= 1) return null
782
+
783
+ const arrayNullable = types.includes('null') || nullable || undefined
784
+ return ast.factory.createSchema({
785
+ type: 'union',
786
+ members: nonNullTypes.map((t) => parseSchema({ schema: { ...schema, type: t } as SchemaObject, name }, rawOptions)),
787
+ ...buildSchemaNode(schema, name, arrayNullable, defaultValue),
788
+ })
789
+ }
790
+
791
+ /**
792
+ * Ordered schema rule table. Order is significant: composition keywords (`$ref`, `allOf`,
793
+ * `oneOf`/`anyOf`) take precedence over `const`/`format`, which take precedence over the plain
794
+ * `type`. The first matching rule that produces a node wins. See {@link SchemaRule} for the
795
+ * match/convert/fall-through contract.
796
+ */
797
+ const schemaRules: Array<SchemaRule> = [
798
+ { name: 'ref', match: ({ schema }) => dialect.isReference(schema), convert: convertRef },
799
+ { name: 'allOf', match: ({ schema }) => !!schema.allOf?.length, convert: convertAllOf },
800
+ { name: 'union', match: ({ schema }) => !!(schema.oneOf?.length || schema.anyOf?.length), convert: convertUnion },
801
+ { name: 'const', match: ({ schema }) => 'const' in schema && schema.const !== undefined, convert: convertConst },
802
+ { name: 'format', match: ({ schema }) => !!schema.format, convert: convertFormat },
803
+ {
804
+ name: 'blob',
805
+ match: ({ schema }) => dialect.isBinary(schema),
806
+ convert: convertBlob,
807
+ },
808
+ { name: 'multi-type', match: ({ schema }) => Array.isArray(schema.type) && schema.type.length > 1, convert: convertMultiType },
809
+ {
810
+ name: 'constrained-string',
811
+ match: ({ schema, type }) => !type && (schema.minLength !== undefined || schema.maxLength !== undefined || schema.pattern !== undefined),
812
+ convert: convertString,
813
+ },
814
+ {
815
+ name: 'constrained-number',
816
+ match: ({ schema, type }) => !type && (schema.minimum !== undefined || schema.maximum !== undefined),
817
+ convert: (ctx) => convertNumeric(ctx, 'number'),
818
+ },
819
+ { name: 'enum', match: ({ schema }) => !!schema.enum?.length, convert: convertEnum },
820
+ {
821
+ name: 'object',
822
+ match: ({ schema, type }) => type === 'object' || !!schema.properties || !!schema.additionalProperties || 'patternProperties' in schema,
823
+ convert: convertObject,
824
+ },
825
+ { name: 'tuple', match: ({ schema }) => 'prefixItems' in schema, convert: convertTuple },
826
+ { name: 'array', match: ({ schema, type }) => type === 'array' || 'items' in schema, convert: convertArray },
827
+ { name: 'string', match: ({ type }) => type === 'string', convert: convertString },
828
+ { name: 'number', match: ({ type }) => type === 'number', convert: (ctx) => convertNumeric(ctx, 'number') },
829
+ { name: 'integer', match: ({ type }) => type === 'integer', convert: (ctx) => convertNumeric(ctx, 'integer') },
830
+ { name: 'boolean', match: ({ type }) => type === 'boolean', convert: convertBoolean },
831
+ { name: 'null', match: ({ type }) => type === 'null', convert: convertNull },
832
+ ]
833
+
834
+ /**
835
+ * Converts an OAS `SchemaObject` into a `SchemaNode`.
836
+ *
837
+ * Builds the per-schema context, then walks the ordered {@link schemaRules} table and returns
838
+ * the first converter that produces a node. When none match, falls back to the configured
839
+ * `emptySchemaType`.
695
840
  */
696
841
  function parseSchema({ schema, name }: { schema: SchemaObject; name?: string | null }, rawOptions?: Partial<ast.ParserOptions>): ast.SchemaNode {
697
842
  const options: ast.ParserOptions = {
@@ -703,11 +848,11 @@ function createSchemaParser(ctx: OasParserContext) {
703
848
  return parseSchema({ schema: flattenedSchema, name }, rawOptions)
704
849
  }
705
850
 
706
- const nullable = isNullable(schema) || undefined
851
+ const nullable = dialect.isNullable(schema) || undefined
707
852
  const defaultValue = schema.default === null && nullable ? undefined : schema.default
708
853
  const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
709
854
 
710
- const ctx: SchemaContext = {
855
+ const schemaCtx: SchemaContext = {
711
856
  schema,
712
857
  name,
713
858
  nullable,
@@ -717,80 +862,36 @@ function createSchemaParser(ctx: OasParserContext) {
717
862
  options,
718
863
  }
719
864
 
720
- if (isReference(schema)) return convertRef(ctx)
721
-
722
- if (schema.allOf?.length) return convertAllOf(ctx)
723
- const unionMembers = [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]
724
- if (unionMembers.length) return convertUnion(ctx)
725
-
726
- if ('const' in schema && schema.const !== undefined) return convertConst(ctx)
727
-
728
- if (schema.format) {
729
- const formatResult = convertFormat(ctx)
730
- if (formatResult) return formatResult
731
- }
732
-
733
- if (schema.type === 'string' && schema.contentMediaType === 'application/octet-stream') {
734
- return ast.createSchema({
735
- type: 'blob',
736
- primitive: 'string',
737
- ...buildSchemaNode(schema, name, nullable, defaultValue),
738
- })
739
- }
740
-
741
- if (Array.isArray(schema.type) && schema.type.length > 1) {
742
- const nonNullTypes = schema.type.filter((t) => t !== 'null') as string[]
743
- const arrayNullable = schema.type.includes('null') || nullable || undefined
744
-
745
- if (nonNullTypes.length > 1) {
746
- return ast.createSchema({
747
- type: 'union',
748
- members: nonNullTypes.map((t) => parseSchema({ schema: { ...schema, type: t } as SchemaObject, name }, rawOptions)),
749
- ...buildSchemaNode(schema, name, arrayNullable, defaultValue),
750
- })
751
- }
865
+ for (const rule of schemaRules) {
866
+ if (!rule.match(schemaCtx)) continue
867
+ const node = rule.convert(schemaCtx)
868
+ if (node) return node
752
869
  }
753
870
 
754
- if (!type) {
755
- if (schema.minLength !== undefined || schema.maxLength !== undefined || schema.pattern !== undefined) {
756
- return convertString(ctx)
757
- }
758
- if (schema.minimum !== undefined || schema.maximum !== undefined) {
759
- return convertNumeric(ctx, 'number')
760
- }
761
- }
762
-
763
- if (schema.enum?.length) return convertEnum(ctx)
764
- if (type === 'object' || schema.properties || schema.additionalProperties || 'patternProperties' in schema) return convertObject(ctx)
765
- if ('prefixItems' in schema) return convertTuple(ctx)
766
- if (type === 'array' || 'items' in schema) return convertArray(ctx)
767
- if (type === 'string') return convertString(ctx)
768
- if (type === 'number') return convertNumeric(ctx, 'number')
769
- if (type === 'integer') return convertNumeric(ctx, 'integer')
770
- if (type === 'boolean') return convertBoolean(ctx)
771
- if (type === 'null') return convertNull(ctx)
772
-
773
871
  const emptyType = typeOptionMap.get(options.emptySchemaType)!
774
- return ast.createSchema({
872
+ return ast.factory.createSchema({
775
873
  type: emptyType as ast.ScalarSchemaType,
776
874
  name,
777
875
  title: schema.title,
778
876
  description: schema.description,
877
+ format: schema.format,
779
878
  })
780
879
  }
781
880
 
782
881
  /**
783
882
  * Converts a dereferenced OAS parameter object into a `ParameterNode`.
784
883
  */
785
- function parseParameter(options: ast.ParserOptions, param: Record<string, unknown>): ast.ParameterNode {
884
+ function parseParameter(options: ast.ParserOptions, param: Record<string, unknown>, parentName?: string): ast.ParameterNode {
786
885
  const required = (param['required'] as boolean | undefined) ?? false
886
+ const paramName = param['name'] as string
887
+ const schemaName = parentName && paramName ? pascalCase(`${parentName} ${paramName}`) : undefined
787
888
 
788
889
  const schema: ast.SchemaNode = param['schema']
789
- ? parseSchema({ schema: param['schema'] as SchemaObject }, options)
790
- : ast.createSchema({ type: typeOptionMap.get(options.unknownType)! })
890
+ ? parseSchema({ schema: param['schema'] as SchemaObject, name: schemaName }, options)
891
+ : ast.factory.createSchema({ type: typeOptionMap.get(options.unknownType)! })
791
892
 
792
- return ast.createParameter({
793
- name: param['name'] as string,
893
+ return ast.factory.createParameter({
894
+ name: paramName,
794
895
  in: param['in'] as ast.ParameterLocation,
795
896
  schema: {
796
897
  ...schema,
@@ -839,25 +940,27 @@ function createSchemaParser(ctx: OasParserContext) {
839
940
  * Collects property names whose schema has a truthy boolean flag (`readOnly` or `writeOnly`).
840
941
  * `$ref` entries are skipped since their flags live on the dereferenced target.
841
942
  */
842
- function collectPropertyKeysByFlag(schema: SchemaObject | null, flag: 'readOnly' | 'writeOnly'): string[] | undefined {
843
- if (!schema?.properties) return undefined
943
+ function collectPropertyKeysByFlag(schema: SchemaObject | null, flag: 'readOnly' | 'writeOnly'): Array<string> | null {
944
+ if (!schema?.properties) return null
844
945
 
845
- const keys: string[] = []
946
+ const keys: Array<string> = []
846
947
  for (const key in schema.properties) {
847
948
  const prop = schema.properties[key]
848
- if (prop && !isReference(prop) && (prop as Record<string, unknown>)[flag]) {
949
+ if (prop && !dialect.isReference(prop) && (prop as Record<string, unknown>)[flag]) {
849
950
  keys.push(key)
850
951
  }
851
952
  }
852
- return keys.length ? keys : undefined
953
+ return keys.length ? keys : null
853
954
  }
854
955
 
855
956
  /**
856
957
  * Converts an OAS `Operation` into an `OperationNode`.
857
958
  */
858
959
  function parseOperation(options: ast.ParserOptions, operation: Operation): ast.OperationNode {
960
+ const operationId = getOperationId(operation)
961
+ const operationName = operationId ? pascalCase(operationId) : undefined
859
962
  const parameters: Array<ast.ParameterNode> = getParameters(document, operation).map((param) =>
860
- parseParameter(options, param as unknown as Record<string, unknown>),
963
+ parseParameter(options, param as unknown as Record<string, unknown>, operationName),
861
964
  )
862
965
 
863
966
  // Determine which content types to include in requestBody.content.
@@ -866,6 +969,7 @@ function createSchemaParser(ctx: OasParserContext) {
866
969
  const allContentTypes = ctx.contentType ? [ctx.contentType] : getRequestBodyContentTypes(document, operation)
867
970
 
868
971
  const requestBodyMeta = getRequestBodyMeta(operation)
972
+ const requestBodyName = operationName ? `${operationName}Request` : undefined
869
973
 
870
974
  const content = allContentTypes.flatMap((ct) => {
871
975
  const schema = getRequestSchema(document, operation, { contentType: ct })
@@ -873,7 +977,7 @@ function createSchemaParser(ctx: OasParserContext) {
873
977
  return [
874
978
  {
875
979
  contentType: ct,
876
- schema: ast.syncOptionality(parseSchema({ schema }, options), requestBodyMeta.required),
980
+ schema: ast.syncOptionality(parseSchema({ schema, name: requestBodyName }, options), requestBodyMeta.required),
877
981
  keysToOmit: collectPropertyKeysByFlag(schema, 'readOnly'),
878
982
  },
879
983
  ]
@@ -888,39 +992,60 @@ function createSchemaParser(ctx: OasParserContext) {
888
992
  }
889
993
  : undefined
890
994
 
891
- const responses: Array<ast.ResponseNode> = operation.getResponseStatusCodes().map((statusCode) => {
892
- const responseObj = operation.getResponseByStatusCode(statusCode)
893
- const responseSchema = getResponseSchema(document, operation, statusCode, { contentType: ctx.contentType })
995
+ const responses: Array<ast.ResponseNode> = getResponseStatusCodes(operation).map((statusCode) => {
996
+ const responseObj = getResponseByStatusCode({ document, operation, statusCode })
997
+
998
+ // Use `Status<code>` (matching plugin-ts's resolveResponseStatusName convention) so the
999
+ // qualified names for nested enums don't collide with top-level component schemas that
1000
+ // happen to be named `<operation><statusCode>` (e.g. `GetMaintenance200`).
1001
+ const responseName = operationName ? `${operationName}Status${statusCode}` : undefined
1002
+ const { description } = getResponseMeta(responseObj)
1003
+
1004
+ const parseEntrySchema = (contentType?: string) => {
1005
+ const raw = getResponseSchema(document, operation, statusCode, { contentType })
1006
+ const node =
1007
+ raw && Object.keys(raw).length > 0
1008
+ ? parseSchema({ schema: raw, name: responseName }, options)
1009
+ : ast.factory.createSchema({ type: typeOptionMap.get(options.emptySchemaType)! })
1010
+ return { schema: node, keysToOmit: collectPropertyKeysByFlag(raw, 'writeOnly') }
1011
+ }
894
1012
 
895
- const schema =
896
- responseSchema && Object.keys(responseSchema).length > 0
897
- ? parseSchema({ schema: responseSchema }, options)
898
- : ast.createSchema({
899
- type: typeOptionMap.get(options.emptySchemaType)!,
900
- })
1013
+ // Build one entry per declared response content type so plugins can union the variants.
1014
+ // When a global contentType is configured, restrict to that single type (mirrors requestBody).
1015
+ const responseContentTypes = ctx.contentType ? [ctx.contentType] : getResponseBodyContentTypes(document, operation, statusCode)
1016
+ const content = responseContentTypes.map((contentType) => ({ contentType, ...parseEntrySchema(contentType) }))
901
1017
 
902
- const { description, content } = getResponseMeta(responseObj)
903
- const mediaType = content ? getMediaType(Object.keys(content)[0] ?? '') : getMediaType(operation.contentType ?? '')
1018
+ // Body-less responses keep a single fallback entry so the response still resolves to a
1019
+ // (void/any) schema, matching how `requestBody` only carries schemas inside `content`.
1020
+ if (content.length === 0) {
1021
+ content.push({ contentType: getRequestContentType({ document, operation }) || 'application/json', ...parseEntrySchema(ctx.contentType) })
1022
+ }
904
1023
 
905
- return ast.createResponse({
1024
+ return ast.factory.createResponse({
906
1025
  statusCode: statusCode as ast.StatusCode,
907
1026
  description,
908
- schema,
909
- mediaType,
910
- keysToOmit: collectPropertyKeysByFlag(responseSchema, 'writeOnly'),
1027
+ content,
911
1028
  })
912
1029
  })
913
1030
 
914
- const urlPath = new URLPath(operation.path)
1031
+ const pathItem = document.paths?.[operation.path]
1032
+ const pathItemDoc = pathItem && !dialect.isReference(pathItem) ? (pathItem as { summary?: unknown; description?: unknown }) : undefined
1033
+ const pickDoc = (key: 'summary' | 'description'): string | undefined => {
1034
+ const own = operation.schema[key]
1035
+ if (typeof own === 'string') return own
1036
+ const fallback = pathItemDoc?.[key]
1037
+ return typeof fallback === 'string' ? fallback : undefined
1038
+ }
915
1039
 
916
- return ast.createOperation({
917
- operationId: operation.getOperationId(),
1040
+ return ast.factory.createOperation({
1041
+ operationId,
1042
+ protocol: 'http',
918
1043
  method: operation.method.toUpperCase() as ast.HttpMethod,
919
- path: urlPath.path,
920
- tags: operation.getTags().map((tag) => tag.name),
921
- summary: operation.getSummary() || undefined,
922
- description: operation.getDescription() || undefined,
923
- deprecated: operation.isDeprecated() || undefined,
1044
+ path: operation.path,
1045
+ tags: Array.isArray(operation.schema.tags) ? operation.schema.tags.map(String) : [],
1046
+ summary: pickDoc('summary') || undefined,
1047
+ description: pickDoc('description') || undefined,
1048
+ deprecated: operation.schema.deprecated || undefined,
924
1049
  parameters,
925
1050
  requestBody,
926
1051
  responses,
@@ -933,10 +1058,11 @@ function createSchemaParser(ctx: OasParserContext) {
933
1058
  /**
934
1059
  * Parses a single OpenAPI `SchemaObject` into a `SchemaNode`.
935
1060
  *
936
- * Use this for targeted schema parsing when you don't need the full spec.
937
- * For complete spec parsing, use `parseOas()` instead which handles operations and all schemas together.
1061
+ * Reach for this when you only need one schema, not the whole spec. To parse a full spec
1062
+ * with its operations and schemas, call `parseOas()`.
938
1063
  *
939
- * @note Circular schema references are tracked via internal state and resolve appropriately.
1064
+ * @note Internal state tracks `$ref` paths under resolution, so circular schemas stop
1065
+ * recursing instead of looping.
940
1066
  *
941
1067
  * @example
942
1068
  * ```ts
@@ -957,8 +1083,8 @@ export function parseSchema(
957
1083
  * Parses an OpenAPI specification into Kubb's universal `InputNode` AST.
958
1084
  *
959
1085
  * This is the main entry point for `@kubb/adapter-oas`. It converts OpenAPI/Swagger specs into a spec-agnostic tree
960
- * that downstream plugins (`plugin-ts`, `plugin-zod`, etc.) consume for code generation. No code is generated here
961
- * the tree is a pure data structure representing all schemas and operations.
1086
+ * that downstream plugins (`plugin-ts`, `plugin-zod`, etc.) consume for code generation. No code is generated here.
1087
+ * The tree is a pure data structure of all schemas and operations.
962
1088
  *
963
1089
  * Returns the AST root and a `nameMapping` for resolving schema references.
964
1090
  *
@@ -987,16 +1113,11 @@ export function parseOas(
987
1113
 
988
1114
  const schemas: Array<ast.SchemaNode> = Object.entries(schemaObjects).map(([name, schema]) => _parseSchema({ schema, name }, mergedOptions))
989
1115
 
990
- const baseOas = new BaseOas(document)
991
- const paths = baseOas.getPaths()
992
-
993
- const operations: Array<ast.OperationNode> = Object.entries(paths).flatMap(([_path, methods]) =>
994
- Object.entries(methods)
995
- .map(([, operation]) => (operation ? _parseOperation(mergedOptions, operation) : null))
996
- .filter((op): op is ast.OperationNode => op !== null),
997
- )
1116
+ const operations: Array<ast.OperationNode> = getOperations(document)
1117
+ .map((operation) => _parseOperation(mergedOptions, operation))
1118
+ .filter((op): op is ast.OperationNode => op !== null)
998
1119
 
999
- const root = ast.createInput({ schemas, operations })
1120
+ const root = ast.factory.createInput({ schemas, operations })
1000
1121
 
1001
1122
  return { root, nameMapping }
1002
1123
  }