@kubb/adapter-oas 5.0.0-beta.5 → 5.0.0-beta.51

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