@kubb/adapter-oas 5.0.0-beta.3 → 5.0.0-beta.31

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,17 @@
1
- import { URLPath } from '@internals/utils'
1
+ import { pascalCase, URLPath } from '@internals/utils'
2
2
  import { ast } from '@kubb/core'
3
3
  import BaseOas from 'oas'
4
4
  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'
5
+ import { oasDialect, type OasDialect } from './dialect.ts'
7
6
  import {
8
7
  buildSchemaNode,
9
8
  flattenSchema,
10
9
  getDateType,
11
- getMediaType,
12
10
  getParameters,
13
11
  getPrimitiveType,
14
12
  getRequestBodyContentTypes,
15
13
  getRequestSchema,
14
+ getResponseBodyContentTypes,
16
15
  getResponseSchema,
17
16
  getSchemas,
18
17
  getSchemaType,
@@ -30,6 +29,16 @@ export type OasParserContext = {
30
29
  contentType?: ContentType
31
30
  }
32
31
 
32
+ /**
33
+ * The object returned by {@link createSchemaParser}.
34
+ * Contains parser functions bound to a specific document.
35
+ */
36
+ export type SchemaParser = {
37
+ parseSchema: (entry: { schema: SchemaObject; name?: string | null }, options?: Partial<ast.ParserOptions>) => ast.SchemaNode
38
+ parseOperation: (options: ast.ParserOptions, operation: Operation) => ast.OperationNode
39
+ parseParameter: (options: ast.ParserOptions, param: Record<string, unknown>) => ast.ParameterNode
40
+ }
41
+
33
42
  /**
34
43
  * Pre-computed per-schema context passed to every schema converter.
35
44
  *
@@ -50,6 +59,13 @@ type SchemaContext = {
50
59
  options: ast.ParserOptions
51
60
  }
52
61
 
62
+ /**
63
+ * One entry in this adapter's schema-dispatch table — a {@link ast.DispatchRule} specialized
64
+ * to OAS schema context and Kubb `SchemaNode` output. See {@link ast.dispatch} for the contract
65
+ * a future adapter (e.g. AsyncAPI) follows: define a context type and an ordered rules table.
66
+ */
67
+ type SchemaRule = ast.DispatchRule<SchemaContext, ast.SchemaNode>
68
+
53
69
  /**
54
70
  * Normalizes malformed `{ type: 'array', enum: [...] }` schemas by moving enum values into items.
55
71
  *
@@ -76,9 +92,9 @@ function normalizeArrayEnum(schema: SchemaObject): SchemaObject {
76
92
  * Each converter branch (`convertRef`, `convertAllOf`, etc.) mutually recursively calls `parseSchema`,
77
93
  * made possible by hoisting of function declarations.
78
94
  *
79
- * @note Not exported; called internally by `parseOas()` and `parseSchema()`.
95
+ * @internal
80
96
  */
81
- function createSchemaParser(ctx: OasParserContext) {
97
+ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect = oasDialect) {
82
98
  const document = ctx.document
83
99
 
84
100
  // Branch handlers — each converts one OAS schema pattern to a SchemaNode.
@@ -89,6 +105,20 @@ function createSchemaParser(ctx: OasParserContext) {
89
105
  */
90
106
  const resolvingRefs = new Set<string>()
91
107
 
108
+ /**
109
+ * Cache of already-resolved `$ref` schemas within this parser instance.
110
+ *
111
+ * Without this, the same referenced schema (e.g. `customer`) is fully re-expanded
112
+ * every time it appears as a `$ref` in a different parent schema. In heavily
113
+ * cross-referenced specs like Stripe (~1 400 schemas), this causes exponential
114
+ * blowup — `customer` alone may be referenced from dozens of top-level schemas,
115
+ * each triggering a fresh recursive expansion of its entire sub-tree.
116
+ *
117
+ * Memoizing by `$ref` path reduces the overall work from O(2^depth) to O(N)
118
+ * where N is the number of unique schema names.
119
+ */
120
+ const resolvedRefCache = new Map<string, ast.SchemaNode | null>()
121
+
92
122
  /**
93
123
  * Converts a `$ref` schema into a `RefSchemaNode`.
94
124
  *
@@ -98,19 +128,23 @@ function createSchemaParser(ctx: OasParserContext) {
98
128
  * Circular refs are detected via `resolvingRefs` and leave `schema` as `undefined`.
99
129
  */
100
130
  function convertRef({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
101
- let resolvedSchema: ast.SchemaNode | undefined
131
+ let resolvedSchema: ast.SchemaNode | null = null
102
132
  const refPath = schema.$ref
103
133
  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)
134
+ if (!resolvedRefCache.has(refPath)) {
135
+ try {
136
+ const referenced = dialect.resolveRef<SchemaObject>(document, refPath)
137
+ if (referenced) {
138
+ resolvingRefs.add(refPath)
139
+ resolvedSchema = parseSchema({ schema: referenced }, rawOptions)
140
+ resolvingRefs.delete(refPath)
141
+ }
142
+ } catch {
143
+ // Ref cannot be resolved in this document (e.g. unit tests with minimal documents).
110
144
  }
111
- } catch {
112
- // Ref cannot be resolved in this document (e.g. unit tests with minimal documents).
145
+ resolvedRefCache.set(refPath, resolvedSchema)
113
146
  }
147
+ resolvedSchema = resolvedRefCache.get(refPath) ?? null
114
148
  }
115
149
 
116
150
  return ast.createSchema({
@@ -133,7 +167,7 @@ function createSchemaParser(ctx: OasParserContext) {
133
167
  schema.additionalProperties === undefined
134
168
  ) {
135
169
  const [memberSchema] = schema.allOf as Array<SchemaObject | ReferenceObject>
136
- const memberNode = parseSchema({ schema: memberSchema! as SchemaObject, name: null }, rawOptions)
170
+ const memberNode = parseSchema({ schema: memberSchema! as SchemaObject, name }, rawOptions)
137
171
  const { kind: _kind, ...memberNodeProps } = memberNode
138
172
  const mergedNullable = nullable || memberNode.nullable || undefined
139
173
  const mergedDefault = schema.default === null && mergedNullable ? undefined : (schema.default ?? memberNode.default)
@@ -150,6 +184,7 @@ function createSchemaParser(ctx: OasParserContext) {
150
184
  default: mergedDefault,
151
185
  example: schema.example ?? memberNode.example,
152
186
  pattern: schema.pattern ?? ('pattern' in memberNode ? memberNode.pattern : undefined),
187
+ format: schema.format ?? memberNode.format,
153
188
  } as ast.DistributiveOmit<ast.SchemaNode, 'kind'>)
154
189
  }
155
190
 
@@ -159,13 +194,13 @@ function createSchemaParser(ctx: OasParserContext) {
159
194
  }> = []
160
195
  const allOfMembers: Array<ast.SchemaNode> = (schema.allOf as Array<SchemaObject | ReferenceObject>)
161
196
  .filter((item) => {
162
- if (!isReference(item) || !name) return true
163
- const deref = resolveRef<SchemaObject>(document, item.$ref)
164
- if (!deref || !isDiscriminator(deref)) return true
197
+ if (!dialect.isReference(item) || !name) return true
198
+ const deref = dialect.resolveRef<SchemaObject>(document, item.$ref)
199
+ if (!deref || !dialect.isDiscriminator(deref)) return true
165
200
  const parentUnion = deref.oneOf ?? deref.anyOf
166
201
  if (!parentUnion) return true
167
202
  const childRef = `${SCHEMA_REF_PREFIX}${name}`
168
- const inOneOf = parentUnion.some((oneOfItem) => isReference(oneOfItem) && oneOfItem.$ref === childRef)
203
+ const inOneOf = parentUnion.some((oneOfItem) => dialect.isReference(oneOfItem) && oneOfItem.$ref === childRef)
169
204
  const inMapping = Object.values(deref.discriminator.mapping ?? {}).some((v) => v === childRef)
170
205
  if (inOneOf || inMapping) {
171
206
  const discriminatorValue = ast.findDiscriminator(deref.discriminator.mapping, childRef)
@@ -179,7 +214,7 @@ function createSchemaParser(ctx: OasParserContext) {
179
214
  }
180
215
  return true
181
216
  })
182
- .map((s) => parseSchema({ schema: s as SchemaObject }, rawOptions))
217
+ .map((s) => parseSchema({ schema: s as SchemaObject, name }, rawOptions))
183
218
 
184
219
  const syntheticStart = allOfMembers.length
185
220
 
@@ -189,9 +224,9 @@ function createSchemaParser(ctx: OasParserContext) {
189
224
 
190
225
  if (missingRequired.length) {
191
226
  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] : []
227
+ if (!dialect.isReference(item)) return [item as SchemaObject]
228
+ const deref = dialect.resolveRef<SchemaObject>(document, item.$ref)
229
+ return deref && !dialect.isReference(deref) ? [deref] : []
195
230
  })
196
231
 
197
232
  for (const key of missingRequired) {
@@ -204,6 +239,7 @@ function createSchemaParser(ctx: OasParserContext) {
204
239
  properties: { [key]: resolved.properties[key] },
205
240
  required: [key],
206
241
  } as SchemaObject,
242
+ name,
207
243
  },
208
244
  rawOptions,
209
245
  ),
@@ -217,6 +253,9 @@ function createSchemaParser(ctx: OasParserContext) {
217
253
 
218
254
  if (schema.properties) {
219
255
  const { allOf: _allOf, ...schemaWithoutAllOf } = schema
256
+ // Don't pass `name` here — the result must stay anonymous so it can be merged with the
257
+ // adjacent synthetic object in `mergeAdjacentObjectsLazy`. Nested enum qualification
258
+ // happens upstream via `convertObject`'s `setEnumName` propagation.
220
259
  allOfMembers.push(parseSchema({ schema: schemaWithoutAllOf }, rawOptions))
221
260
  }
222
261
 
@@ -226,7 +265,7 @@ function createSchemaParser(ctx: OasParserContext) {
226
265
 
227
266
  return ast.createSchema({
228
267
  type: 'intersection',
229
- members: [...ast.mergeAdjacentObjects(allOfMembers.slice(0, syntheticStart)), ...ast.mergeAdjacentObjects(allOfMembers.slice(syntheticStart))],
268
+ members: [...ast.mergeAdjacentObjectsLazy(allOfMembers.slice(0, syntheticStart)), ...ast.mergeAdjacentObjectsLazy(allOfMembers.slice(syntheticStart))],
230
269
  ...buildSchemaNode(schema, name, nullable, defaultValue),
231
270
  })
232
271
  }
@@ -254,10 +293,10 @@ function createSchemaParser(ctx: OasParserContext) {
254
293
  const strategy: 'one' | 'any' = schema.oneOf ? 'one' : 'any'
255
294
  const unionBase = {
256
295
  ...buildSchemaNode(schema, name, nullable, defaultValue),
257
- discriminatorPropertyName: isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
296
+ discriminatorPropertyName: dialect.isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
258
297
  strategy,
259
298
  }
260
- const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
299
+ const discriminator = dialect.isDiscriminator(schema) ? schema.discriminator : undefined
261
300
  const sharedPropertiesNode = schema.properties
262
301
  ? (() => {
263
302
  const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
@@ -270,9 +309,9 @@ function createSchemaParser(ctx: OasParserContext) {
270
309
 
271
310
  if (sharedPropertiesNode || discriminator?.mapping) {
272
311
  const members = unionMembers.map((s) => {
273
- const ref = isReference(s) ? s.$ref : undefined
312
+ const ref = dialect.isReference(s) ? s.$ref : undefined
274
313
  const discriminatorValue = ast.findDiscriminator(discriminator?.mapping, ref)
275
- const memberNode = parseSchema({ schema: s as SchemaObject }, rawOptions)
314
+ const memberNode = parseSchema({ schema: s as SchemaObject, name }, rawOptions)
276
315
 
277
316
  if (!discriminatorValue || !discriminator) {
278
317
  return memberNode
@@ -322,7 +361,7 @@ function createSchemaParser(ctx: OasParserContext) {
322
361
  return ast.createSchema({
323
362
  type: 'union',
324
363
  ...unionBase,
325
- members: ast.simplifyUnion(unionMembers.map((s) => parseSchema({ schema: s as SchemaObject }, rawOptions))),
364
+ members: ast.simplifyUnion(unionMembers.map((s) => parseSchema({ schema: s as SchemaObject, name }, rawOptions))),
326
365
  })
327
366
  }
328
367
 
@@ -340,6 +379,7 @@ function createSchemaParser(ctx: OasParserContext) {
340
379
  title: schema.title,
341
380
  description: schema.description,
342
381
  deprecated: schema.deprecated,
382
+ format: schema.format,
343
383
  })
344
384
  }
345
385
 
@@ -454,6 +494,22 @@ function createSchemaParser(ctx: OasParserContext) {
454
494
 
455
495
  const nullInEnum = schema.enum!.includes(null)
456
496
  const filteredValues = (nullInEnum ? schema.enum!.filter((v) => v !== null) : schema.enum!) as Array<string | number | boolean>
497
+
498
+ // drf-spectacular `NullEnum` ({ enum: [null] }) is just `null`; an empty enum node would
499
+ // render as `never` (plugin-ts) / invalid `z.enum([])` (plugin-zod). Mirror the `const: null`
500
+ // branch so it renders as a clean `null` (not `z.null().nullable()`).
501
+ if (nullInEnum && filteredValues.length === 0) {
502
+ return ast.createSchema({
503
+ type: 'null',
504
+ primitive: 'null',
505
+ name,
506
+ title: schema.title,
507
+ description: schema.description,
508
+ deprecated: schema.deprecated,
509
+ format: schema.format,
510
+ })
511
+ }
512
+
457
513
  const enumNullable = nullable || nullInEnum || undefined
458
514
  const enumDefault = schema.default === null && enumNullable ? undefined : schema.default
459
515
  const enumPrimitive = getPrimitiveType(type)
@@ -470,6 +526,7 @@ function createSchemaParser(ctx: OasParserContext) {
470
526
  writeOnly: schema.writeOnly,
471
527
  default: enumDefault,
472
528
  example: schema.example,
529
+ format: schema.format,
473
530
  }
474
531
 
475
532
  const extensionKey = enumExtensionKeys.find((key) => key in schema)
@@ -513,19 +570,21 @@ function createSchemaParser(ctx: OasParserContext) {
513
570
  ? Object.entries(schema.properties).map(([propName, propSchema]) => {
514
571
  const required = Array.isArray(schema.required) ? schema.required.includes(propName) : !!schema.required
515
572
  const resolvedPropSchema = propSchema as SchemaObject
516
- const propNullable = isNullable(resolvedPropSchema)
573
+ const propNullable = dialect.isNullable(resolvedPropSchema)
517
574
 
518
575
  const resolvedChildName = ast.childName(name, propName)
519
576
  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 }
577
+ const schemaNode = (() => {
578
+ const node = ast.setEnumName(propNode, name, propName, options.enumSuffix)
579
+ const tupleNode = ast.narrowSchema(node, 'tuple')
580
+ if (tupleNode?.items) {
581
+ const namedItems = tupleNode.items.map((item) => ast.setEnumName(item, name, propName, options.enumSuffix))
582
+ if (namedItems.some((item, i) => item !== tupleNode.items![i])) {
583
+ return { ...tupleNode, items: namedItems }
584
+ }
527
585
  }
528
- }
586
+ return node
587
+ })()
529
588
 
530
589
  return ast.createProperty({
531
590
  name: propName,
@@ -539,18 +598,15 @@ function createSchemaParser(ctx: OasParserContext) {
539
598
  : []
540
599
 
541
600
  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
- }
601
+ const additionalPropertiesNode: ast.SchemaNode | boolean | undefined = (() => {
602
+ if (additionalProperties === true) return true
603
+ if (additionalProperties === false) return false
604
+ if (additionalProperties && Object.keys(additionalProperties).length > 0) {
605
+ return parseSchema({ schema: additionalProperties as SchemaObject }, rawOptions)
606
+ }
607
+ if (additionalProperties) return ast.createSchema({ type: typeOptionMap.get(options.unknownType)! })
608
+ return undefined
609
+ })()
554
610
 
555
611
  const rawPatternProperties = 'patternProperties' in schema ? schema.patternProperties : undefined
556
612
 
@@ -578,7 +634,7 @@ function createSchemaParser(ctx: OasParserContext) {
578
634
  ...buildSchemaNode(schema, name, nullable, defaultValue),
579
635
  })
580
636
 
581
- if (isDiscriminator(schema) && schema.discriminator.mapping) {
637
+ if (dialect.isDiscriminator(schema) && schema.discriminator.mapping) {
582
638
  const discPropName = schema.discriminator.propertyName
583
639
  const values = Object.keys(schema.discriminator.mapping)
584
640
  const enumName = name ? ast.enumPropName(name, discPropName, options.enumSuffix) : undefined
@@ -616,7 +672,7 @@ function createSchemaParser(ctx: OasParserContext) {
616
672
  */
617
673
  function convertArray({ schema, name, nullable, defaultValue, rawOptions, options }: SchemaContext): ast.SchemaNode {
618
674
  const rawItems = schema.items as SchemaObject | undefined
619
- const itemName = rawItems?.enum?.length && name ? ast.enumPropName(undefined, name, options.enumSuffix) : undefined
675
+ const itemName = rawItems?.enum?.length && name ? ast.enumPropName(null, name, options.enumSuffix) : name
620
676
  const items = rawItems ? [parseSchema({ schema: rawItems, name: itemName }, rawOptions)] : []
621
677
 
622
678
  return ast.createSchema({
@@ -683,15 +739,90 @@ function createSchemaParser(ctx: OasParserContext) {
683
739
  description: schema.description,
684
740
  deprecated: schema.deprecated,
685
741
  nullable,
742
+ format: schema.format,
743
+ })
744
+ }
745
+
746
+ /**
747
+ * Converts a binary string schema (`type: 'string'`, `contentMediaType: 'application/octet-stream'`)
748
+ * into a `blob` node.
749
+ */
750
+ function convertBlob({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
751
+ return ast.createSchema({
752
+ type: 'blob',
753
+ primitive: 'string',
754
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
686
755
  })
687
756
  }
688
757
 
758
+ /**
759
+ * Converts an OAS 3.1 multi-type array (e.g. `type: ['string', 'number']`) into a `UnionSchemaNode`.
760
+ *
761
+ * Returns `null` when only one non-`null` type remains (e.g. `['string', 'null']`), so `parseSchema`
762
+ * falls through and handles it as that single type with nullability already folded in.
763
+ */
764
+ function convertMultiType({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode | null {
765
+ const types = schema.type as Array<string>
766
+ const nonNullTypes = types.filter((t) => t !== 'null')
767
+ if (nonNullTypes.length <= 1) return null
768
+
769
+ const arrayNullable = types.includes('null') || nullable || undefined
770
+ return ast.createSchema({
771
+ type: 'union',
772
+ members: nonNullTypes.map((t) => parseSchema({ schema: { ...schema, type: t } as SchemaObject, name }, rawOptions)),
773
+ ...buildSchemaNode(schema, name, arrayNullable, defaultValue),
774
+ })
775
+ }
776
+
777
+ /**
778
+ * Ordered schema-dispatch table. Order is significant: composition keywords (`$ref`, `allOf`,
779
+ * `oneOf`/`anyOf`) take precedence over `const`/`format`, which take precedence over the plain
780
+ * `type`. The first matching rule that produces a node wins; see {@link SchemaRule} for the
781
+ * match/convert/fall-through contract.
782
+ */
783
+ const schemaRules: Array<SchemaRule> = [
784
+ { name: 'ref', match: ({ schema }) => dialect.isReference(schema), convert: convertRef },
785
+ { name: 'allOf', match: ({ schema }) => !!schema.allOf?.length, convert: convertAllOf },
786
+ { name: 'union', match: ({ schema }) => !!(schema.oneOf?.length || schema.anyOf?.length), convert: convertUnion },
787
+ { name: 'const', match: ({ schema }) => 'const' in schema && schema.const !== undefined, convert: convertConst },
788
+ { name: 'format', match: ({ schema }) => !!schema.format, convert: convertFormat },
789
+ {
790
+ name: 'blob',
791
+ match: ({ schema }) => dialect.isBinary(schema),
792
+ convert: convertBlob,
793
+ },
794
+ { name: 'multi-type', match: ({ schema }) => Array.isArray(schema.type) && schema.type.length > 1, convert: convertMultiType },
795
+ {
796
+ name: 'constrained-string',
797
+ match: ({ schema, type }) => !type && (schema.minLength !== undefined || schema.maxLength !== undefined || schema.pattern !== undefined),
798
+ convert: convertString,
799
+ },
800
+ {
801
+ name: 'constrained-number',
802
+ match: ({ schema, type }) => !type && (schema.minimum !== undefined || schema.maximum !== undefined),
803
+ convert: (ctx) => convertNumeric(ctx, 'number'),
804
+ },
805
+ { name: 'enum', match: ({ schema }) => !!schema.enum?.length, convert: convertEnum },
806
+ {
807
+ name: 'object',
808
+ match: ({ schema, type }) => type === 'object' || !!schema.properties || !!schema.additionalProperties || 'patternProperties' in schema,
809
+ convert: convertObject,
810
+ },
811
+ { name: 'tuple', match: ({ schema }) => 'prefixItems' in schema, convert: convertTuple },
812
+ { name: 'array', match: ({ schema, type }) => type === 'array' || 'items' in schema, convert: convertArray },
813
+ { name: 'string', match: ({ type }) => type === 'string', convert: convertString },
814
+ { name: 'number', match: ({ type }) => type === 'number', convert: (ctx) => convertNumeric(ctx, 'number') },
815
+ { name: 'integer', match: ({ type }) => type === 'integer', convert: (ctx) => convertNumeric(ctx, 'integer') },
816
+ { name: 'boolean', match: ({ type }) => type === 'boolean', convert: convertBoolean },
817
+ { name: 'null', match: ({ type }) => type === 'null', convert: convertNull },
818
+ ]
819
+
689
820
  /**
690
821
  * Central dispatcher that converts an OAS `SchemaObject` into a `SchemaNode`.
691
822
  *
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).
823
+ * Builds the per-schema context, then runs it through the ordered {@link schemaRules} table
824
+ * via {@link ast.dispatch}. When no rule produces a node, falls back to the configured
825
+ * `emptySchemaType`.
695
826
  */
696
827
  function parseSchema({ schema, name }: { schema: SchemaObject; name?: string | null }, rawOptions?: Partial<ast.ParserOptions>): ast.SchemaNode {
697
828
  const options: ast.ParserOptions = {
@@ -703,11 +834,11 @@ function createSchemaParser(ctx: OasParserContext) {
703
834
  return parseSchema({ schema: flattenedSchema, name }, rawOptions)
704
835
  }
705
836
 
706
- const nullable = isNullable(schema) || undefined
837
+ const nullable = dialect.isNullable(schema) || undefined
707
838
  const defaultValue = schema.default === null && nullable ? undefined : schema.default
708
839
  const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
709
840
 
710
- const ctx: SchemaContext = {
841
+ const schemaCtx: SchemaContext = {
711
842
  schema,
712
843
  name,
713
844
  nullable,
@@ -717,58 +848,8 @@ function createSchemaParser(ctx: OasParserContext) {
717
848
  options,
718
849
  }
719
850
 
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
- }
752
- }
753
-
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)
851
+ const node = ast.dispatch(schemaRules, schemaCtx)
852
+ if (node) return node
772
853
 
773
854
  const emptyType = typeOptionMap.get(options.emptySchemaType)!
774
855
  return ast.createSchema({
@@ -776,21 +857,24 @@ function createSchemaParser(ctx: OasParserContext) {
776
857
  name,
777
858
  title: schema.title,
778
859
  description: schema.description,
860
+ format: schema.format,
779
861
  })
780
862
  }
781
863
 
782
864
  /**
783
865
  * Converts a dereferenced OAS parameter object into a `ParameterNode`.
784
866
  */
785
- function parseParameter(options: ast.ParserOptions, param: Record<string, unknown>): ast.ParameterNode {
867
+ function parseParameter(options: ast.ParserOptions, param: Record<string, unknown>, parentName?: string): ast.ParameterNode {
786
868
  const required = (param['required'] as boolean | undefined) ?? false
869
+ const paramName = param['name'] as string
870
+ const schemaName = parentName && paramName ? pascalCase(`${parentName} ${paramName}`) : undefined
787
871
 
788
872
  const schema: ast.SchemaNode = param['schema']
789
- ? parseSchema({ schema: param['schema'] as SchemaObject }, options)
873
+ ? parseSchema({ schema: param['schema'] as SchemaObject, name: schemaName }, options)
790
874
  : ast.createSchema({ type: typeOptionMap.get(options.unknownType)! })
791
875
 
792
876
  return ast.createParameter({
793
- name: param['name'] as string,
877
+ name: paramName,
794
878
  in: param['in'] as ast.ParameterLocation,
795
879
  schema: {
796
880
  ...schema,
@@ -839,25 +923,27 @@ function createSchemaParser(ctx: OasParserContext) {
839
923
  * Collects property names whose schema has a truthy boolean flag (`readOnly` or `writeOnly`).
840
924
  * `$ref` entries are skipped since their flags live on the dereferenced target.
841
925
  */
842
- function collectPropertyKeysByFlag(schema: SchemaObject | null, flag: 'readOnly' | 'writeOnly'): string[] | undefined {
843
- if (!schema?.properties) return undefined
926
+ function collectPropertyKeysByFlag(schema: SchemaObject | null, flag: 'readOnly' | 'writeOnly'): Array<string> | null {
927
+ if (!schema?.properties) return null
844
928
 
845
- const keys: string[] = []
929
+ const keys: Array<string> = []
846
930
  for (const key in schema.properties) {
847
931
  const prop = schema.properties[key]
848
- if (prop && !isReference(prop) && (prop as Record<string, unknown>)[flag]) {
932
+ if (prop && !dialect.isReference(prop) && (prop as Record<string, unknown>)[flag]) {
849
933
  keys.push(key)
850
934
  }
851
935
  }
852
- return keys.length ? keys : undefined
936
+ return keys.length ? keys : null
853
937
  }
854
938
 
855
939
  /**
856
940
  * Converts an OAS `Operation` into an `OperationNode`.
857
941
  */
858
942
  function parseOperation(options: ast.ParserOptions, operation: Operation): ast.OperationNode {
943
+ const operationId = operation.getOperationId()
944
+ const operationName = operationId ? pascalCase(operationId) : undefined
859
945
  const parameters: Array<ast.ParameterNode> = getParameters(document, operation).map((param) =>
860
- parseParameter(options, param as unknown as Record<string, unknown>),
946
+ parseParameter(options, param as unknown as Record<string, unknown>, operationName),
861
947
  )
862
948
 
863
949
  // Determine which content types to include in requestBody.content.
@@ -866,6 +952,7 @@ function createSchemaParser(ctx: OasParserContext) {
866
952
  const allContentTypes = ctx.contentType ? [ctx.contentType] : getRequestBodyContentTypes(document, operation)
867
953
 
868
954
  const requestBodyMeta = getRequestBodyMeta(operation)
955
+ const requestBodyName = operationName ? `${operationName}Request` : undefined
869
956
 
870
957
  const content = allContentTypes.flatMap((ct) => {
871
958
  const schema = getRequestSchema(document, operation, { contentType: ct })
@@ -873,7 +960,7 @@ function createSchemaParser(ctx: OasParserContext) {
873
960
  return [
874
961
  {
875
962
  contentType: ct,
876
- schema: ast.syncOptionality(parseSchema({ schema }, options), requestBodyMeta.required),
963
+ schema: ast.syncOptionality(parseSchema({ schema, name: requestBodyName }, options), requestBodyMeta.required),
877
964
  keysToOmit: collectPropertyKeysByFlag(schema, 'readOnly'),
878
965
  },
879
966
  ]
@@ -890,31 +977,45 @@ function createSchemaParser(ctx: OasParserContext) {
890
977
 
891
978
  const responses: Array<ast.ResponseNode> = operation.getResponseStatusCodes().map((statusCode) => {
892
979
  const responseObj = operation.getResponseByStatusCode(statusCode)
893
- const responseSchema = getResponseSchema(document, operation, statusCode, { contentType: ctx.contentType })
894
980
 
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
- })
981
+ // Use `Status<code>` (matching plugin-ts's resolveResponseStatusName convention) so the
982
+ // qualified names for nested enums don't collide with top-level component schemas that
983
+ // happen to be named `<operation><statusCode>` (e.g. `GetMaintenance200`).
984
+ const responseName = operationName ? `${operationName}Status${statusCode}` : undefined
985
+ const { description } = getResponseMeta(responseObj)
986
+
987
+ const parseEntrySchema = (contentType?: string) => {
988
+ const raw = getResponseSchema(document, operation, statusCode, { contentType })
989
+ const node =
990
+ raw && Object.keys(raw).length > 0
991
+ ? parseSchema({ schema: raw, name: responseName }, options)
992
+ : ast.createSchema({ type: typeOptionMap.get(options.emptySchemaType)! })
993
+ return { schema: node, keysToOmit: collectPropertyKeysByFlag(raw, 'writeOnly') }
994
+ }
901
995
 
902
- const { description, content } = getResponseMeta(responseObj)
903
- const mediaType = content ? getMediaType(Object.keys(content)[0] ?? '') : getMediaType(operation.contentType ?? '')
996
+ // Build one entry per declared response content type so plugins can union the variants.
997
+ // When a global contentType is configured, restrict to that single type (mirrors requestBody).
998
+ const responseContentTypes = ctx.contentType ? [ctx.contentType] : getResponseBodyContentTypes(document, operation, statusCode)
999
+ const content = responseContentTypes.map((contentType) => ({ contentType, ...parseEntrySchema(contentType) }))
1000
+
1001
+ // Body-less responses keep a single fallback entry so the response still resolves to a
1002
+ // (void/any) schema, matching how `requestBody` only carries schemas inside `content`.
1003
+ if (content.length === 0) {
1004
+ content.push({ contentType: operation.contentType || 'application/json', ...parseEntrySchema(ctx.contentType) })
1005
+ }
904
1006
 
905
1007
  return ast.createResponse({
906
1008
  statusCode: statusCode as ast.StatusCode,
907
1009
  description,
908
- schema,
909
- mediaType,
910
- keysToOmit: collectPropertyKeysByFlag(responseSchema, 'writeOnly'),
1010
+ content,
911
1011
  })
912
1012
  })
913
1013
 
914
1014
  const urlPath = new URLPath(operation.path)
915
1015
 
916
1016
  return ast.createOperation({
917
- operationId: operation.getOperationId(),
1017
+ operationId,
1018
+ protocol: 'http',
918
1019
  method: operation.method.toUpperCase() as ast.HttpMethod,
919
1020
  path: urlPath.path,
920
1021
  tags: operation.getTags().map((tag) => tag.name),
@@ -990,7 +1091,7 @@ export function parseOas(
990
1091
  const baseOas = new BaseOas(document)
991
1092
  const paths = baseOas.getPaths()
992
1093
 
993
- const operations: Array<ast.OperationNode> = Object.entries(paths).flatMap(([_path, methods]) =>
1094
+ const operations: Array<ast.OperationNode> = Object.entries(paths).flatMap(([, methods]) =>
994
1095
  Object.entries(methods)
995
1096
  .map(([, operation]) => (operation ? _parseOperation(mergedOptions, operation) : null))
996
1097
  .filter((op): op is ast.OperationNode => op !== null),