@kubb/adapter-oas 5.0.0-alpha.14 → 5.0.0-alpha.16

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,127 +1,50 @@
1
- import { getUniqueName, pascalCase, URLPath } from '@internals/utils'
2
- import { createOperation, createParameter, createProperty, createResponse, createRoot, createSchema, narrowSchema, schemaTypes, transform } from '@kubb/ast'
1
+ import { URLPath } from '@internals/utils'
2
+ import {
3
+ applyDiscriminatorEnum,
4
+ createOperation,
5
+ createParameter,
6
+ createProperty,
7
+ createResponse,
8
+ createRoot,
9
+ createSchema,
10
+ extractRefName,
11
+ mediaTypes,
12
+ mergeAdjacentAnonymousObjects,
13
+ narrowSchema,
14
+ schemaTypes,
15
+ simplifyUnionMembers,
16
+ } from '@kubb/ast'
3
17
  import type {
4
- ArraySchemaNode,
5
- DateSchemaNode,
6
- DatetimeSchemaNode,
7
- EnumSchemaNode,
18
+ DistributiveOmit,
8
19
  HttpMethod,
9
- IntersectionSchemaNode,
10
20
  MediaType,
11
- NumberSchemaNode,
12
- ObjectSchemaNode,
13
21
  OperationNode,
14
22
  ParameterLocation,
15
23
  ParameterNode,
16
24
  PrimitiveSchemaType,
17
25
  PropertyNode,
18
- RefSchemaNode,
19
26
  ResponseNode,
20
27
  RootNode,
21
- ScalarSchemaNode,
22
28
  ScalarSchemaType,
23
29
  SchemaNode,
24
30
  SchemaType,
25
31
  StatusCode,
26
- StringSchemaNode,
27
- TimeSchemaNode,
28
- UnionSchemaNode,
29
32
  } from '@kubb/ast/types'
30
- import { DEFAULT_PARSER_OPTIONS, enumExtensionKeys, formatMap, knownMediaTypes } from './constants.ts'
33
+ import { DEFAULT_PARSER_OPTIONS, enumExtensionKeys, formatMap } from './constants.ts'
34
+ import { createDiscriminantNode, resolveDiscriminatorValue } from './discriminator.ts'
35
+ import type { InferSchemaNode } from './infer.ts'
36
+ import { applyEnumName, resolveChildName, resolveEnumPropName } from './naming.ts'
31
37
  import type { Oas } from './oas/Oas.ts'
32
38
  import type { contentType, Operation, ReferenceObject, SchemaObject } from './oas/types.ts'
33
39
  import { flattenSchema, isDiscriminator, isNullable, isReference } from './oas/utils.ts'
40
+ import { resolveRefs as resolveRefsNode } from './refResolver.ts'
34
41
  import type { ParserOptions } from './types.ts'
35
- import { applyDiscriminatorEnum, extractRefName, mergeAdjacentAnonymousObjects, simplifyUnionMembers } from './utils.ts'
36
-
37
- /**
38
- * Distributive `Omit` — correctly distributes over union types so that
39
- * `Omit<A | B, 'kind'>` produces `Omit<A, 'kind'> | Omit<B, 'kind'>`
40
- * rather than `Omit<A | B, 'kind'>`.
41
- */
42
- type DistributiveOmit<TValue, TKey extends PropertyKey> = TValue extends unknown ? Omit<TValue, TKey> : never
43
-
44
- /**
45
- * Maps each `dateType` option value to the AST node produced by `format: 'date-time'`.
46
- */
47
- type DateTimeNodeByDateType = {
48
- date: DateSchemaNode
49
- string: DatetimeSchemaNode
50
- stringOffset: DatetimeSchemaNode
51
- stringLocal: DatetimeSchemaNode
52
- false: StringSchemaNode
53
- }
54
-
55
- /**
56
- * Resolves the AST node produced by `format: 'date-time'` based on the `dateType` option.
57
- */
58
- type ResolveDateTimeNode<TDateType extends ParserOptions['dateType']> = DateTimeNodeByDateType[TDateType extends keyof DateTimeNodeByDateType
59
- ? TDateType
60
- : 'string']
61
-
62
- /**
63
- * Single source of truth: ordered list of `[shape, SchemaNode]` pairs.
64
- * `InferSchemaNode` walks this tuple in order and returns the node type of the first matching entry.
65
- * Parameterized over `TDateType` so `format: 'date-time'` resolves to the correct node based on the option.
66
- */
67
- type SchemaNodeMap<TDateType extends ParserOptions['dateType'] = 'string'> = [
68
- [{ $ref: string }, RefSchemaNode],
69
- // allOf with sibling `properties` always produces an intersection (shared props are appended as a member).
70
- [{ allOf: ReadonlyArray<unknown>; properties: object }, IntersectionSchemaNode],
71
- // allOf with 2+ members always produces an intersection.
72
- [{ allOf: readonly [unknown, unknown, ...unknown[]] }, IntersectionSchemaNode],
73
- // Single-member allOf without sibling `properties` flattens to the member type.
74
- [{ allOf: ReadonlyArray<unknown> }, SchemaNode],
75
- [{ oneOf: ReadonlyArray<unknown> }, UnionSchemaNode],
76
- [{ anyOf: ReadonlyArray<unknown> }, UnionSchemaNode],
77
- [{ const: null }, ScalarSchemaNode],
78
- [{ const: string | number | boolean }, EnumSchemaNode],
79
- // OAS 3.1 multi-type array: `{ type: ['string', 'integer'] }` → union node.
80
- [{ type: ReadonlyArray<string> }, UnionSchemaNode],
81
- // `{ type: 'array', enum }` is normalized at runtime: enum moves into items → array node.
82
- [{ type: 'array'; enum: ReadonlyArray<unknown> }, ArraySchemaNode],
83
- [{ enum: ReadonlyArray<unknown> }, EnumSchemaNode],
84
- [{ type: 'object' }, ObjectSchemaNode],
85
- [{ additionalProperties: boolean | {} }, ObjectSchemaNode],
86
- [{ type: 'array' }, ArraySchemaNode],
87
- [{ items: object }, ArraySchemaNode],
88
- [{ prefixItems: ReadonlyArray<unknown> }, ArraySchemaNode],
89
- // Format entries with explicit type — placed before generic type entries so format wins.
90
- [{ type: string; format: 'date-time' }, ResolveDateTimeNode<TDateType>],
91
- [{ type: string; format: 'date' }, DateSchemaNode],
92
- [{ type: string; format: 'time' }, TimeSchemaNode],
93
- [{ format: 'date-time' }, ResolveDateTimeNode<TDateType>],
94
- [{ format: 'date' }, DateSchemaNode],
95
- [{ format: 'time' }, TimeSchemaNode],
96
- [{ type: 'string' }, StringSchemaNode],
97
- [{ type: 'number' }, NumberSchemaNode],
98
- [{ type: 'integer' }, NumberSchemaNode],
99
- [{ type: 'bigint' }, NumberSchemaNode],
100
- [{ type: string }, ScalarSchemaNode],
101
- // Inferred scalar types from constraints when no explicit type is present.
102
- [{ minLength: number }, StringSchemaNode],
103
- [{ maxLength: number }, StringSchemaNode],
104
- [{ pattern: string }, StringSchemaNode],
105
- [{ minimum: number }, NumberSchemaNode],
106
- [{ maximum: number }, NumberSchemaNode],
107
- ]
108
-
109
- export type InferSchemaNode<
110
- TSchema extends SchemaObject,
111
- TDateType extends ParserOptions['dateType'] = 'string',
112
- TEntries extends ReadonlyArray<[object, SchemaNode]> = SchemaNodeMap<TDateType>,
113
- > = TEntries extends [infer TEntry extends [object, SchemaNode], ...infer TRest extends ReadonlyArray<[object, SchemaNode]>]
114
- ? TSchema extends TEntry[0]
115
- ? TEntry[1]
116
- : InferSchemaNode<TSchema, TDateType, TRest>
117
- : SchemaNode
118
42
 
119
43
  /**
120
44
  * Construction-time options for `createOasParser`.
121
45
  */
122
46
  export type OasParserOptions = {
123
47
  contentType?: contentType
124
- collisionDetection?: boolean
125
48
  }
126
49
 
127
50
  /**
@@ -149,7 +72,7 @@ function getPrimitiveType(type: string | undefined): PrimitiveSchemaType {
149
72
  * Returns `undefined` for content types not present in `KNOWN_MEDIA_TYPES`.
150
73
  */
151
74
  function toMediaType(contentType: string): MediaType | undefined {
152
- return knownMediaTypes.has(contentType as MediaType) ? (contentType as MediaType) : undefined
75
+ return Object.values(mediaTypes).includes(contentType as MediaType) ? (contentType as MediaType) : undefined
153
76
  }
154
77
 
155
78
  /**
@@ -165,8 +88,8 @@ type SchemaContext = {
165
88
  * Normalized single type string (first element when OAS 3.1 multi-type array).
166
89
  */
167
90
  type: string | undefined
168
- options: Partial<ParserOptions> | undefined
169
- mergedOptions: ParserOptions
91
+ rawOptions: Partial<ParserOptions> | undefined
92
+ options: ParserOptions
170
93
  }
171
94
 
172
95
  /**
@@ -221,27 +144,30 @@ export type OasParser = {
221
144
  * const root = parser.parse({ emptySchemaType: 'unknown' })
222
145
  * ```
223
146
  */
224
- export function createOasParser(oas: Oas, { contentType, collisionDetection }: OasParserOptions = {}): OasParser {
147
+ export function createOasParser(oas: Oas, { contentType }: OasParserOptions = {}): OasParser {
225
148
  // Map from original component paths to resolved schema names (after collision resolution)
226
149
  // e.g., { '#/components/schemas/Order': 'OrderSchema', '#/components/responses/Product': 'ProductResponse' }
227
- const { schemas: schemaObjects, nameMapping } = oas.getSchemas({ contentType, collisionDetection })
228
-
229
- // Legacy enum name deduplication: tracks used enum names and appends numeric suffixes
230
- // (e.g. ParamsStatusEnum, ParamsStatusEnum2) when collisionDetection is disabled.
231
- const usedEnumNames: Record<string, number> = {}
150
+ const { schemas: schemaObjects, nameMapping } = oas.getSchemas({ contentType })
232
151
 
233
- // Only apply legacy naming when collisionDetection is explicitly false.
234
- // When undefined (e.g. direct parser usage without adapter), use the default (new) behavior.
235
- const isLegacyNaming = collisionDetection === false
152
+ const TYPE_OPTION_MAP: Record<'any' | 'unknown' | 'void', ScalarSchemaType> = {
153
+ any: schemaTypes.any,
154
+ unknown: schemaTypes.unknown,
155
+ void: schemaTypes.void,
156
+ }
236
157
 
237
158
  /**
238
159
  * Maps an `'any' | 'unknown' | 'void'` option string to the corresponding `SchemaType` constant.
239
160
  * Used for both `unknownType` (unannotated schemas) and `emptySchemaType` (empty `{}` schemas).
240
161
  */
241
162
  function resolveTypeOption(value: 'any' | 'unknown' | 'void'): ScalarSchemaType {
242
- if (value === 'any') return schemaTypes.any
243
- if (value === 'void') return schemaTypes.void
244
- return schemaTypes.unknown
163
+ return TYPE_OPTION_MAP[value]
164
+ }
165
+
166
+ function normalizeArrayEnum(schema: SchemaObject): SchemaObject {
167
+ const isItemsObject = typeof schema.items === 'object' && !Array.isArray(schema.items)
168
+ const normalizedItems: SchemaObject = { ...(isItemsObject ? (schema.items as SchemaObject) : {}), enum: schema.enum }
169
+ const { enum: _enum, ...schemaWithoutEnum } = schema
170
+ return { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject
245
171
  }
246
172
 
247
173
  /**
@@ -337,7 +263,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
337
263
  * Circular references through discriminator parents are detected and skipped to prevent
338
264
  * infinite recursion during code generation.
339
265
  */
340
- function convertAllOf({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
266
+ function convertAllOf({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): SchemaNode {
341
267
  if (
342
268
  schema.allOf!.length === 1 &&
343
269
  !schema.properties &&
@@ -345,7 +271,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
345
271
  schema.additionalProperties === undefined
346
272
  ) {
347
273
  const [memberSchema] = schema.allOf as Array<SchemaObject | ReferenceObject>
348
- const memberNode = convertSchema({ schema: memberSchema! as SchemaObject }, options)
274
+ const memberNode = convertSchema({ schema: memberSchema! as SchemaObject }, rawOptions)
349
275
  const { kind: _kind, ...memberNodeProps } = memberNode
350
276
  const mergedNullable = nullable || memberNode.nullable || undefined
351
277
  const mergedDefault = schema.default === null && mergedNullable ? undefined : (schema.default ?? memberNode.default)
@@ -380,7 +306,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
380
306
  const inOneOf = parentUnion.some((oneOfItem) => isReference(oneOfItem) && oneOfItem.$ref === childRef)
381
307
  const inMapping = Object.values(deref.discriminator.mapping ?? {}).some((v) => v === childRef)
382
308
  if (inOneOf || inMapping) {
383
- const discriminatorValue = Object.entries(deref.discriminator.mapping ?? {}).find(([, v]) => v === childRef)?.[0]
309
+ const discriminatorValue = resolveDiscriminatorValue(deref.discriminator.mapping, childRef)
384
310
  if (discriminatorValue) {
385
311
  filteredDiscriminantValues.push({ propertyName: deref.discriminator.propertyName, value: discriminatorValue })
386
312
  }
@@ -388,7 +314,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
388
314
  }
389
315
  return true
390
316
  })
391
- .map((s) => convertSchema({ schema: s as SchemaObject }, options))
317
+ .map((s) => convertSchema({ schema: s as SchemaObject }, rawOptions))
392
318
 
393
319
  // Track where allOf-derived members end so each portion can be merged independently.
394
320
  const syntheticStart = allOfMembers.length
@@ -409,7 +335,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
409
335
  for (const key of missingRequired) {
410
336
  for (const resolved of resolvedMembers) {
411
337
  if (resolved.properties?.[key]) {
412
- allOfMembers.push(convertSchema({ schema: { properties: { [key]: resolved.properties[key] }, required: [key] } as SchemaObject }, options))
338
+ allOfMembers.push(convertSchema({ schema: { properties: { [key]: resolved.properties[key] }, required: [key] } as SchemaObject }, rawOptions))
413
339
  break
414
340
  }
415
341
  }
@@ -419,29 +345,13 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
419
345
 
420
346
  if (schema.properties) {
421
347
  const { allOf: _allOf, ...schemaWithoutAllOf } = schema
422
- allOfMembers.push(convertSchema({ schema: schemaWithoutAllOf }, options))
348
+ allOfMembers.push(convertSchema({ schema: schemaWithoutAllOf }, rawOptions))
423
349
  }
424
350
 
425
351
  // Inject a synthetic single-property object for each discriminant value collected from
426
352
  // filtered discriminator parents so that child schemas carry the narrowed literal type.
427
353
  for (const { propertyName, value } of filteredDiscriminantValues) {
428
- allOfMembers.push(
429
- createSchema({
430
- type: 'object',
431
- primitive: 'object',
432
- properties: [
433
- createProperty({
434
- name: propertyName,
435
- schema: createSchema({
436
- type: 'enum',
437
- primitive: 'string',
438
- enumValues: [value],
439
- }),
440
- required: true,
441
- }),
442
- ],
443
- }),
444
- )
354
+ allOfMembers.push(createDiscriminantNode(propertyName, value))
445
355
  }
446
356
 
447
357
  // Merge consecutive anonymous object members within the synthetic portion — see `mergeAdjacentAnonymousObjects`.
@@ -460,81 +370,56 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
460
370
  * individually intersected with the shared properties node to match the OAS pattern of
461
371
  * adding common fields next to a discriminated union.
462
372
  */
463
- function convertUnion({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
373
+ function convertUnion({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): SchemaNode {
464
374
  const unionMembers = [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]
465
375
  const unionBase = {
466
376
  ...renderSchemaBase(schema, name, nullable, defaultValue),
467
377
  discriminatorPropertyName: isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
468
378
  }
379
+ const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
380
+ const sharedPropertiesNode = schema.properties
381
+ ? (() => {
382
+ const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
383
+ // Strip discriminator so convertObject won't re-apply the full mapping enum.
384
+ const memberBaseSchema: SchemaObject = discriminator
385
+ ? (Object.fromEntries(Object.entries(schemaWithoutUnion).filter(([key]) => key !== 'discriminator')) as SchemaObject)
386
+ : schemaWithoutUnion
387
+ // Convert shared properties once to avoid duplicate enum naming
388
+ // (e.g. StatusEnum appearing twice and getting a numeric suffix).
389
+ return convertSchema({ schema: memberBaseSchema, name }, rawOptions)
390
+ })()
391
+ : undefined
469
392
 
470
- if (schema.properties) {
471
- const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
472
- const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
473
-
474
- // Strip discriminator so convertObject won't re-apply the full mapping enum.
475
- const memberBaseSchema: SchemaObject = discriminator
476
- ? (Object.fromEntries(Object.entries(schemaWithoutUnion).filter(([key]) => key !== 'discriminator')) as SchemaObject)
477
- : schemaWithoutUnion
478
-
479
- // Convert shared properties once to avoid duplicate enum naming
480
- // (e.g. StatusEnum appearing twice and getting a numeric suffix).
481
- const sharedPropertiesNode = convertSchema({ schema: memberBaseSchema, name: isLegacyNaming ? undefined : name }, options)
482
-
393
+ if (sharedPropertiesNode || discriminator?.mapping) {
483
394
  return createSchema({
484
395
  type: 'union',
485
396
  ...unionBase,
486
397
  members: unionMembers.map((s) => {
487
398
  const ref = isReference(s) ? s.$ref : undefined
488
- const discriminatorValue = discriminator?.mapping && ref ? Object.entries(discriminator.mapping).find(([, v]) => v === ref)?.[0] : undefined
489
-
490
- let propertiesNode = sharedPropertiesNode
491
-
492
- if (discriminatorValue && discriminator) {
493
- propertiesNode = applyDiscriminatorEnum({ node: propertiesNode, propertyName: discriminator.propertyName, values: [discriminatorValue] })
399
+ const discriminatorValue = resolveDiscriminatorValue(discriminator?.mapping, ref)
400
+ const memberNode = convertSchema({ schema: s as SchemaObject }, rawOptions)
401
+
402
+ if (sharedPropertiesNode) {
403
+ const narrowedProperties =
404
+ discriminatorValue && discriminator
405
+ ? applyDiscriminatorEnum({
406
+ node: sharedPropertiesNode,
407
+ propertyName: discriminator.propertyName,
408
+ values: [discriminatorValue],
409
+ })
410
+ : sharedPropertiesNode
411
+
412
+ return createSchema({
413
+ type: 'intersection',
414
+ members: [memberNode, narrowedProperties],
415
+ })
494
416
  }
495
417
 
496
- return createSchema({
497
- type: 'intersection',
498
- members: [convertSchema({ schema: s as SchemaObject }, options), propertiesNode],
499
- })
500
- }),
501
- })
502
- }
503
-
504
- // When a discriminator with mapping is present but there are no sibling properties,
505
- // embed the narrowed discriminant value into each member to produce precise literal
506
- // intersection types (e.g. `Cat & { type: 'cat' }`) instead of plain `Cat | Dog`.
507
- const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
508
- if (discriminator?.mapping) {
509
- return createSchema({
510
- type: 'union',
511
- ...unionBase,
512
- members: unionMembers.map((s) => {
513
- const ref = isReference(s) ? s.$ref : undefined
514
- const discriminatorValue = ref ? Object.entries(discriminator.mapping!).find(([, v]) => v === ref)?.[0] : undefined
515
- const memberNode = convertSchema({ schema: s as SchemaObject }, options)
516
-
517
- if (!discriminatorValue) return memberNode
518
-
519
- const discriminantNode = createSchema({
520
- type: 'object',
521
- primitive: 'object',
522
- properties: [
523
- createProperty({
524
- name: discriminator.propertyName,
525
- schema: createSchema({
526
- type: 'enum',
527
- primitive: 'string',
528
- enumValues: [discriminatorValue],
529
- }),
530
- required: true,
531
- }),
532
- ],
533
- })
418
+ if (!discriminatorValue || !discriminator) return memberNode
534
419
 
535
420
  return createSchema({
536
421
  type: 'intersection',
537
- members: [memberNode, discriminantNode],
422
+ members: [memberNode, createDiscriminantNode(discriminator.propertyName, discriminatorValue)],
538
423
  })
539
424
  }),
540
425
  })
@@ -543,7 +428,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
543
428
  return createSchema({
544
429
  type: 'union',
545
430
  ...unionBase,
546
- members: simplifyUnionMembers(unionMembers.map((s) => convertSchema({ schema: s as SchemaObject }, options))),
431
+ members: simplifyUnionMembers(unionMembers.map((s) => convertSchema({ schema: s as SchemaObject }, rawOptions))),
547
432
  })
548
433
  }
549
434
 
@@ -582,13 +467,13 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
582
467
  * Returns `undefined` when the format should fall through to string handling
583
468
  * (i.e. `format: 'date-time'` with `dateType: false`).
584
469
  */
585
- function convertFormat({ schema, name, nullable, defaultValue, mergedOptions }: SchemaContext): SchemaNode | undefined {
470
+ function convertFormat({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode | undefined {
586
471
  const base = renderSchemaBase(schema, name, nullable, defaultValue)
587
472
 
588
473
  // int64 is option-dependent so it can't live in the static formatMap.
589
474
  if (schema.format === 'int64') {
590
475
  return createSchema({
591
- type: mergedOptions.integerType === 'bigint' ? 'bigint' : 'integer',
476
+ type: options.integerType === 'bigint' ? 'bigint' : 'integer',
592
477
  primitive: 'integer',
593
478
  ...base,
594
479
  min: schema.minimum,
@@ -600,7 +485,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
600
485
 
601
486
  // date-time / date / time are option-dependent and can't live in the static formatMap.
602
487
  if (schema.format === 'date-time' || schema.format === 'date' || schema.format === 'time') {
603
- const dateType = getDateType(mergedOptions, schema.format)
488
+ const dateType = getDateType(options, schema.format)
604
489
  if (!dateType) return undefined // dateType: false → fall through to string
605
490
 
606
491
  if (dateType.type === 'datetime') {
@@ -634,13 +519,10 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
634
519
  * - Numeric and boolean enums require a const-map representation because most generators cannot
635
520
  * use string-enum syntax for non-string values.
636
521
  */
637
- function convertEnum({ schema, name, nullable, type, options }: SchemaContext): SchemaNode {
522
+ function convertEnum({ schema, name, nullable, type, rawOptions }: SchemaContext): SchemaNode {
638
523
  // Malformed schema: `{ type: 'array', enum: [...] }` — normalize by moving the enum into items.
639
524
  if (type === 'array') {
640
- const isItemsObject = typeof schema.items === 'object' && !Array.isArray(schema.items)
641
- const normalizedItems: SchemaObject = { ...(isItemsObject ? (schema.items as SchemaObject) : {}), enum: schema.enum }
642
- const { enum: _enum, ...schemaWithoutEnum } = schema
643
- return convertSchema({ schema: { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject, name }, options)
525
+ return convertSchema({ schema: normalizeArrayEnum(schema), name }, rawOptions)
644
526
  }
645
527
 
646
528
  // `null` in enum values is the OAS 3.0 convention for a nullable enum.
@@ -666,53 +548,26 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
666
548
 
667
549
  // x-enumNames / x-enum-varnames: named variants with explicit labels take priority.
668
550
  const extensionKey = enumExtensionKeys.find((key) => key in schema)
669
- if (extensionKey) {
670
- const rawNames = (schema as Record<string, unknown>)[extensionKey] as Array<string | number>
671
- const uniqueNames = [...new Set(rawNames)]
672
- const enumType =
673
- getPrimitiveType(type) === 'number' || getPrimitiveType(type) === 'integer'
674
- ? ('number' as const)
675
- : getPrimitiveType(type) === 'boolean'
676
- ? ('boolean' as const)
677
- : ('string' as const)
551
+ if (extensionKey || enumPrimitive === 'number' || enumPrimitive === 'integer' || enumPrimitive === 'boolean') {
552
+ const enumType = (enumPrimitive === 'number' || enumPrimitive === 'integer' ? 'number' : enumPrimitive === 'boolean' ? 'boolean' : 'string') as
553
+ | 'number'
554
+ | 'boolean'
555
+ | 'string'
556
+ const sourceValues = extensionKey
557
+ ? [...new Set((schema as Record<string, unknown>)[extensionKey] as Array<string | number>)]
558
+ : [...new Set(filteredValues)]
678
559
 
679
560
  return createSchema({
680
561
  ...enumBase,
681
562
  enumType,
682
- namedEnumValues: uniqueNames.map((label, index) => ({
563
+ namedEnumValues: sourceValues.map((label, index) => ({
683
564
  name: String(label),
684
- value: filteredValues[index] ?? label,
565
+ value: extensionKey ? (filteredValues[index] ?? label) : label,
685
566
  format: enumType,
686
567
  })),
687
568
  })
688
569
  }
689
570
 
690
- // Number / integer enum — must use a const map since most generators can't use string-enum for numbers.
691
- if (type === 'number' || type === 'integer') {
692
- return createSchema({
693
- ...enumBase,
694
- enumType: 'number' as const,
695
- namedEnumValues: [...new Set(filteredValues)].map((value) => ({
696
- name: String(value),
697
- value: value as number,
698
- format: 'number' as const,
699
- })),
700
- })
701
- }
702
-
703
- // Boolean enum — same const-map approach as numeric.
704
- if (type === 'boolean') {
705
- return createSchema({
706
- ...enumBase,
707
- enumType: 'boolean' as const,
708
- namedEnumValues: [...new Set(filteredValues)].map((value) => ({
709
- name: String(value),
710
- value: value as boolean,
711
- format: 'boolean' as const,
712
- })),
713
- })
714
- }
715
-
716
571
  // Plain string enum (default path).
717
572
  return createSchema({
718
573
  ...enumBase,
@@ -732,55 +587,8 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
732
587
  * - not required + not nullable → `optional: true`
733
588
  * - not required + nullable → `nullish: true`
734
589
  */
735
- /**
736
- * Builds the propagation name for a child property during recursive schema conversion.
737
- *
738
- * - **Legacy naming** (`isLegacyNaming`): only the immediate property key is used
739
- * (e.g. `Params` for property `params`), keeping nested names short.
740
- * - **Default naming**: the parent name is prepended so the full path is encoded
741
- * (e.g. `OrderParams` when parent is `Order`).
742
- */
743
- function resolveChildName(parentName: string | undefined, propName: string): string | undefined {
744
- if (isLegacyNaming) {
745
- return pascalCase(propName)
746
- }
747
- return parentName ? pascalCase([parentName, propName].join(' ')) : undefined
748
- }
749
-
750
- /**
751
- * Derives the final name for an enum property schema node.
752
- *
753
- * The raw name always includes the enum suffix (e.g. `StatusEnum`).
754
- * In legacy mode an additional deduplication step appends a numeric suffix
755
- * when the same name has already been used (e.g. `ParamsStatusEnum2`).
756
- */
757
- function resolveEnumPropName(parentName: string | undefined, propName: string, enumSuffix: string): string {
758
- const raw = pascalCase([parentName, propName, enumSuffix].filter(Boolean).join(' '))
759
- return isLegacyNaming ? getUniqueName(raw, usedEnumNames) : raw
760
- }
761
590
 
762
- /**
763
- * Given a freshly-converted property schema, returns the node with a correct
764
- * `name` attached — or stripped — depending on whether the node is a named
765
- * enum, a boolean const-enum (always inlined), or a regular schema.
766
- */
767
- function applyEnumName(propNode: SchemaNode, parentName: string | undefined, propName: string, enumSuffix: string): SchemaNode {
768
- const enumNode = narrowSchema(propNode, 'enum')
769
-
770
- // Boolean-primitive enum nodes (e.g. `const: false`) are always inlined as
771
- // literal types and must not receive a named identifier.
772
- if (enumNode?.primitive === 'boolean') {
773
- return { ...propNode, name: undefined }
774
- }
775
-
776
- if (enumNode) {
777
- return { ...propNode, name: resolveEnumPropName(parentName, propName, enumSuffix) }
778
- }
779
-
780
- return propNode
781
- }
782
-
783
- function convertObject({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
591
+ function convertObject({ schema, name, nullable, defaultValue, rawOptions, options }: SchemaContext): SchemaNode {
784
592
  const properties: Array<PropertyNode> = schema.properties
785
593
  ? Object.entries(schema.properties).map(([propName, propSchema]) => {
786
594
  const required = Array.isArray(schema.required) ? schema.required.includes(propName) : !!schema.required
@@ -788,12 +596,12 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
788
596
  const propNullable = isNullable(resolvedPropSchema)
789
597
 
790
598
  const childName = resolveChildName(name, propName)
791
- const propNode = convertSchema({ schema: resolvedPropSchema, name: childName }, options)
792
- let schemaNode = applyEnumName(propNode, name, propName, mergedOptions.enumSuffix)
599
+ const propNode = convertSchema({ schema: resolvedPropSchema, name: childName }, rawOptions)
600
+ let schemaNode = applyEnumName(propNode, name, propName, options.enumSuffix)
793
601
 
794
602
  const tupleNode = narrowSchema(schemaNode, 'tuple')
795
603
  if (tupleNode?.items) {
796
- const namedItems = tupleNode.items.map((item) => applyEnumName(item, name, propName, mergedOptions.enumSuffix))
604
+ const namedItems = tupleNode.items.map((item) => applyEnumName(item, name, propName, options.enumSuffix))
797
605
  if (namedItems.some((item, i) => item !== tupleNode.items![i])) {
798
606
  schemaNode = { ...tupleNode, items: namedItems }
799
607
  }
@@ -815,11 +623,11 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
815
623
  if (additionalProperties === true) {
816
624
  additionalPropertiesNode = true
817
625
  } else if (additionalProperties && Object.keys(additionalProperties).length > 0) {
818
- additionalPropertiesNode = convertSchema({ schema: additionalProperties as SchemaObject }, options)
626
+ additionalPropertiesNode = convertSchema({ schema: additionalProperties as SchemaObject }, rawOptions)
819
627
  } else if (additionalProperties === false) {
820
628
  additionalPropertiesNode = undefined
821
629
  } else if (additionalProperties) {
822
- additionalPropertiesNode = createSchema({ type: resolveTypeOption(mergedOptions.unknownType) })
630
+ additionalPropertiesNode = createSchema({ type: resolveTypeOption(options.unknownType) })
823
631
  }
824
632
 
825
633
  const rawPatternProperties = 'patternProperties' in schema ? schema.patternProperties : undefined
@@ -829,8 +637,8 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
829
637
  Object.entries(rawPatternProperties).map(([pattern, patternSchema]) => [
830
638
  pattern,
831
639
  patternSchema === true || (typeof patternSchema === 'object' && Object.keys(patternSchema).length === 0)
832
- ? createSchema({ type: resolveTypeOption(mergedOptions.unknownType) })
833
- : convertSchema({ schema: patternSchema as SchemaObject }, options),
640
+ ? createSchema({ type: resolveTypeOption(options.unknownType) })
641
+ : convertSchema({ schema: patternSchema as SchemaObject }, rawOptions),
834
642
  ]),
835
643
  )
836
644
  : undefined
@@ -849,7 +657,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
849
657
  if (isDiscriminator(schema) && schema.discriminator.mapping) {
850
658
  const discPropName = schema.discriminator.propertyName
851
659
  const values = Object.keys(schema.discriminator.mapping)
852
- const enumName = name ? resolveEnumPropName(name, discPropName, mergedOptions.enumSuffix) : undefined
660
+ const enumName = name ? resolveEnumPropName(name, discPropName, options.enumSuffix) : undefined
853
661
  return applyDiscriminatorEnum({ node: objectNode, propertyName: discPropName, values, enumName })
854
662
  }
855
663
 
@@ -864,9 +672,9 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
864
672
  * a rest element of type `any` is emitted to match JSON Schema semantics (absent `items`
865
673
  * means additional items are allowed).
866
674
  */
867
- function convertTuple({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
868
- const tupleItems = (schema.prefixItems ?? []).map((item) => convertSchema({ schema: item as SchemaObject }, options))
869
- const rest = schema.items ? convertSchema({ schema: schema.items as SchemaObject }, options) : createSchema({ type: 'any' })
675
+ function convertTuple({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): SchemaNode {
676
+ const tupleItems = (schema.prefixItems ?? []).map((item) => convertSchema({ schema: item as SchemaObject }, rawOptions))
677
+ const rest = schema.items ? convertSchema({ schema: schema.items as SchemaObject }, rawOptions) : createSchema({ type: 'any' })
870
678
 
871
679
  return createSchema({
872
680
  type: 'tuple',
@@ -885,12 +693,12 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
885
693
  * When the items schema is an inline enum, a name derived from the parent array's name and
886
694
  * `enumSuffix` is forwarded so generators can emit a named enum declaration.
887
695
  */
888
- function convertArray({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
696
+ function convertArray({ schema, name, nullable, defaultValue, rawOptions, options }: SchemaContext): SchemaNode {
889
697
  const rawItems = schema.items as SchemaObject | undefined
890
698
  // When the items schema contains an inline enum, derive a named identifier
891
699
  // so generators can emit a standalone enum declaration.
892
- const itemName = rawItems?.enum?.length && name ? resolveEnumPropName(undefined, name, mergedOptions.enumSuffix) : undefined
893
- const items = rawItems ? [convertSchema({ schema: rawItems, name: itemName }, options)] : []
700
+ const itemName = rawItems?.enum?.length && name ? resolveEnumPropName(undefined, name, options.enumSuffix) : undefined
701
+ const items = rawItems ? [convertSchema({ schema: rawItems, name: itemName }, rawOptions)] : []
894
702
 
895
703
  return createSchema({
896
704
  type: 'array',
@@ -974,13 +782,13 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
974
782
  * 10. Object / array / tuple / scalar by `type`
975
783
  * 11. Empty schema fallback (`emptySchemaType` option)
976
784
  */
977
- function convertSchema({ schema, name }: { schema: SchemaObject; name?: string }, options?: Partial<ParserOptions>): SchemaNode {
978
- const mergedOptions: ParserOptions = { ...DEFAULT_PARSER_OPTIONS, ...options }
785
+ function convertSchema({ schema, name }: { schema: SchemaObject; name?: string }, rawOptions?: Partial<ParserOptions>): SchemaNode {
786
+ const options: ParserOptions = { ...DEFAULT_PARSER_OPTIONS, ...rawOptions }
979
787
  // Flatten keyword-only allOf fragments (no $ref, no structural keys) into the parent
980
788
  // schema before parsing, so simple annotation patterns don't produce needless intersections.
981
789
  const flattenedSchema = flattenSchema(schema)
982
790
  if (flattenedSchema && flattenedSchema !== schema) {
983
- return convertSchema({ schema: flattenedSchema, name }, options)
791
+ return convertSchema({ schema: flattenedSchema, name }, rawOptions)
984
792
  }
985
793
 
986
794
  const nullable = isNullable(schema) || undefined
@@ -988,7 +796,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
988
796
  // Normalize OAS 3.1 multi-type array to a single type string for the dispatch below.
989
797
  const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
990
798
 
991
- const ctx: SchemaContext = { schema, name, nullable, defaultValue, type, options, mergedOptions }
799
+ const ctx: SchemaContext = { schema, name, nullable, defaultValue, type, rawOptions, options }
992
800
 
993
801
  // $ref — pointer to another definition.
994
802
  // In OAS 3.0 siblings of $ref are technically ignored, but Kubb intentionally preserves them
@@ -1027,7 +835,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1027
835
  if (nonNullTypes.length > 1) {
1028
836
  return createSchema({
1029
837
  type: 'union',
1030
- members: nonNullTypes.map((t) => convertSchema({ schema: { ...schema, type: t } as SchemaObject, name }, options)),
838
+ members: nonNullTypes.map((t) => convertSchema({ schema: { ...schema, type: t } as SchemaObject, name }, rawOptions)),
1031
839
  ...renderSchemaBase(schema, name, arrayNullable, defaultValue),
1032
840
  })
1033
841
  }
@@ -1055,7 +863,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1055
863
  if (type === 'boolean') return convertBoolean(ctx)
1056
864
  if (type === 'null') return convertNull(ctx)
1057
865
 
1058
- const emptyType = resolveTypeOption(mergedOptions.emptySchemaType)
866
+ const emptyType = resolveTypeOption(options.emptySchemaType)
1059
867
  return createSchema({ type: emptyType as ScalarSchemaType, name, title: schema.title, description: schema.description })
1060
868
  }
1061
869
 
@@ -1179,36 +987,11 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1179
987
  return createRoot({ schemas, operations })
1180
988
  }
1181
989
 
1182
- /**
1183
- * Walks a `SchemaNode` tree and resolves all `ref` node names through the provided callbacks.
1184
- *
1185
- * `resolveName` handles all schema types; `resolveEnumName` (when provided) takes precedence
1186
- * for `enum` nodes, enabling a separate naming strategy for enums (e.g. different suffix).
1187
- *
1188
- * Collision-resolved names (from `nameMapping`) are applied before user-supplied resolvers.
1189
- */
1190
- function resolveRefs(node: SchemaNode, resolveName: (ref: string) => string | undefined, resolveEnumName?: (name: string) => string | undefined): SchemaNode {
1191
- return transform(node, {
1192
- schema(schemaNode) {
1193
- const schemaRef = narrowSchema(schemaNode, schemaTypes.ref)
1194
-
1195
- if (schemaRef && (schemaRef.ref || schemaRef.name)) {
1196
- const rawRef = schemaRef.ref ?? schemaRef.name!
1197
- const resolved = resolveName(nameMapping.get(rawRef) ?? rawRef)
1198
- if (resolved) {
1199
- return { ...schemaNode, name: resolved }
1200
- }
1201
- }
1202
-
1203
- if (schemaNode.type === 'enum' && schemaNode.name) {
1204
- const resolved = (resolveEnumName ?? resolveName)(schemaNode.name)
1205
- if (resolved) {
1206
- return { ...schemaNode, name: resolved }
1207
- }
1208
- }
1209
- },
1210
- }) as SchemaNode
1211
- }
990
+ const resolveRefs = (
991
+ node: SchemaNode,
992
+ resolveName: (ref: string) => string | undefined,
993
+ resolveEnumName?: (name: string) => string | undefined,
994
+ ): SchemaNode => resolveRefsNode({ node, nameMapping, resolveName, resolveEnumName })
1212
995
 
1213
996
  return {
1214
997
  parse,