@kubb/adapter-oas 5.0.0-beta.4 → 5.0.0-beta.41

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