@kubb/adapter-oas 5.0.0-alpha.1 → 5.0.0-alpha.11

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,16 +1,5 @@
1
- import { pascalCase } from '@internals/utils'
2
- import {
3
- collect,
4
- createOperation,
5
- createParameter,
6
- createProperty,
7
- createResponse,
8
- createRoot,
9
- createSchema,
10
- narrowSchema,
11
- schemaTypes,
12
- transform,
13
- } from '@kubb/ast'
1
+ import { getUniqueName, pascalCase, URLPath } from '@internals/utils'
2
+ import { createOperation, createParameter, createProperty, createResponse, createRoot, createSchema, narrowSchema, schemaTypes, transform } from '@kubb/ast'
14
3
  import type {
15
4
  ArraySchemaNode,
16
5
  DateSchemaNode,
@@ -38,11 +27,12 @@ import type {
38
27
  TimeSchemaNode,
39
28
  UnionSchemaNode,
40
29
  } from '@kubb/ast/types'
41
- import type { KubbFile } from '@kubb/fabric-core/types'
42
- import { enumExtensionKeys, formatMap, knownMediaTypes } from './constants.ts'
30
+ import { DEFAULT_PARSER_OPTIONS, enumExtensionKeys, formatMap, knownMediaTypes } from './constants.ts'
43
31
  import type { Oas } from './oas/Oas.ts'
44
- import type { contentType, Operation, SchemaObject } from './oas/types.ts'
32
+ import type { contentType, Operation, ReferenceObject, SchemaObject } from './oas/types.ts'
45
33
  import { flattenSchema, isDiscriminator, isNullable, isReference } from './oas/utils.ts'
34
+ import type { ParserOptions } from './types.ts'
35
+ import { applyDiscriminatorEnum, extractRefName, mergeAdjacentAnonymousObjects, simplifyUnionMembers } from './utils.ts'
46
36
 
47
37
  /**
48
38
  * Distributive `Omit` — correctly distributes over union types so that
@@ -65,14 +55,16 @@ type DateTimeNodeByDateType = {
65
55
  /**
66
56
  * Resolves the AST node produced by `format: 'date-time'` based on the `dateType` option.
67
57
  */
68
- type ResolveDateTimeNode<TDateType extends Options['dateType']> = DateTimeNodeByDateType[TDateType extends keyof DateTimeNodeByDateType ? TDateType : 'string']
58
+ type ResolveDateTimeNode<TDateType extends ParserOptions['dateType']> = DateTimeNodeByDateType[TDateType extends keyof DateTimeNodeByDateType
59
+ ? TDateType
60
+ : 'string']
69
61
 
70
62
  /**
71
63
  * Single source of truth: ordered list of `[shape, SchemaNode]` pairs.
72
64
  * `InferSchemaNode` walks this tuple in order and returns the node type of the first matching entry.
73
65
  * Parameterized over `TDateType` so `format: 'date-time'` resolves to the correct node based on the option.
74
66
  */
75
- type SchemaNodeMap<TDateType extends Options['dateType'] = Options['dateType']> = [
67
+ type SchemaNodeMap<TDateType extends ParserOptions['dateType'] = 'string'> = [
76
68
  [{ $ref: string }, RefSchemaNode],
77
69
  // allOf with sibling `properties` always produces an intersection (shared props are appended as a member).
78
70
  [{ allOf: ReadonlyArray<unknown>; properties: object }, IntersectionSchemaNode],
@@ -116,7 +108,7 @@ type SchemaNodeMap<TDateType extends Options['dateType'] = Options['dateType']>
116
108
 
117
109
  export type InferSchemaNode<
118
110
  TSchema extends SchemaObject,
119
- TDateType extends Options['dateType'] = Options['dateType'],
111
+ TDateType extends ParserOptions['dateType'] = 'string',
120
112
  TEntries extends ReadonlyArray<[object, SchemaNode]> = SchemaNodeMap<TDateType>,
121
113
  > = TEntries extends [infer TEntry extends [object, SchemaNode], ...infer TRest extends ReadonlyArray<[object, SchemaNode]>]
122
114
  ? TSchema extends TEntry[0]
@@ -124,32 +116,6 @@ export type InferSchemaNode<
124
116
  : InferSchemaNode<TSchema, TDateType, TRest>
125
117
  : SchemaNode
126
118
 
127
- /**
128
- * Controls how various OAS constructs are mapped to Kubb AST nodes.
129
- */
130
- export type Options = {
131
- /**
132
- * How `format: 'date-time'` schemas are represented. `false` falls through to a plain string.
133
- */
134
- dateType: false | 'string' | 'stringOffset' | 'stringLocal' | 'date'
135
- /**
136
- * Whether `type: 'integer'` and `format: 'int64'` produce `number` or `bigint` nodes.
137
- */
138
- integerType?: 'number' | 'bigint'
139
- /**
140
- * AST type used when no schema type can be inferred.
141
- */
142
- unknownType: 'any' | 'unknown' | 'void'
143
- /**
144
- * AST type used for completely empty schemas (`{}`).
145
- */
146
- emptySchemaType: 'any' | 'unknown' | 'void'
147
- /**
148
- * Suffix appended to derived enum names when building property schema names.
149
- */
150
- enumSuffix: string
151
- }
152
-
153
119
  /**
154
120
  * Construction-time options for `createOasParser`.
155
121
  */
@@ -158,17 +124,6 @@ export type OasParserOptions = {
158
124
  collisionDetection?: boolean
159
125
  }
160
126
 
161
- /**
162
- * Default values for all `Options` fields.
163
- */
164
- const DEFAULT_OPTIONS = {
165
- dateType: 'string',
166
- integerType: 'number',
167
- unknownType: 'any',
168
- emptySchemaType: 'any',
169
- enumSuffix: 'enum',
170
- } as const satisfies Options
171
-
172
127
  /**
173
128
  * Looks up the Kubb `SchemaType` for a given OAS `format` string.
174
129
  * Returns `undefined` for formats not in `formatMap` (e.g. `int64`, `date-time`),
@@ -178,15 +133,6 @@ function formatToSchemaType(format: string): SchemaType | undefined {
178
133
  return formatMap[format as keyof typeof formatMap]
179
134
  }
180
135
 
181
- /**
182
- * Extracts the final path segment of a JSON Pointer `$ref` string.
183
- * For `#/components/schemas/Order` this returns `'Order'`.
184
- * Falls back to the full ref string when no slash is present.
185
- */
186
- function extractRefName($ref: string): string {
187
- return $ref.split('/').at(-1) ?? $ref
188
- }
189
-
190
136
  /**
191
137
  * Maps an OAS primitive type string to its `PrimitiveSchemaType` equivalent.
192
138
  * Numeric types (`number`, `integer`, `bigint`) are returned unchanged;
@@ -203,7 +149,7 @@ function getPrimitiveType(type: string | undefined): PrimitiveSchemaType {
203
149
  * Returns `undefined` for content types not present in `KNOWN_MEDIA_TYPES`.
204
150
  */
205
151
  function toMediaType(contentType: string): MediaType | undefined {
206
- return knownMediaTypes.includes(contentType as MediaType) ? (contentType as MediaType) : undefined
152
+ return knownMediaTypes.has(contentType as MediaType) ? (contentType as MediaType) : undefined
207
153
  }
208
154
 
209
155
  /**
@@ -219,8 +165,8 @@ type SchemaContext = {
219
165
  * Normalized single type string (first element when OAS 3.1 multi-type array).
220
166
  */
221
167
  type: string | undefined
222
- options: Partial<Options> | undefined
223
- mergedOptions: Options
168
+ options: Partial<ParserOptions> | undefined
169
+ mergedOptions: ParserOptions
224
170
  }
225
171
 
226
172
  /**
@@ -231,11 +177,11 @@ export type OasParser = {
231
177
  * Converts an OpenAPI/Swagger spec (wrapped in a Kubb `Oas` instance) into
232
178
  * a `RootNode` — the top-level node of the `@kubb/ast` tree.
233
179
  */
234
- parse: <TOptions extends Partial<Options> = object>(options?: TOptions) => RootNode
235
- convertSchema: <TFormat extends string, TSchema extends SchemaObject & { format?: TFormat }, TOptions extends Partial<Options> = object>(
180
+ parse: <TOptions extends Partial<ParserOptions> = object>(options?: TOptions) => RootNode
181
+ convertSchema: <TFormat extends string, TSchema extends SchemaObject & { format?: TFormat }, TOptions extends Partial<ParserOptions> = object>(
236
182
  params: { schema: TSchema; name?: string },
237
183
  options?: TOptions,
238
- ) => InferSchemaNode<TSchema, TOptions extends { dateType: Options['dateType'] } ? TOptions['dateType'] : (typeof DEFAULT_OPTIONS)['dateType']>
184
+ ) => InferSchemaNode<TSchema, TOptions extends { dateType: ParserOptions['dateType'] } ? TOptions['dateType'] : 'string'>
239
185
  /**
240
186
  * Walks `node` and replaces each `ref` value with the name returned by
241
187
  * `resolveName`. The callback receives the full `$ref` path (e.g. `#/components/schemas/Order`)
@@ -245,45 +191,15 @@ export type OasParser = {
245
191
  * the transformed name to use (e.g. with a plugin `transformers.name` applied).
246
192
  */
247
193
  resolveRefs: (node: SchemaNode, resolveName: (ref: string) => string | undefined, resolveEnumName?: (name: string) => string | undefined) => SchemaNode
194
+
248
195
  /**
249
- * Extracts `KubbFile.Import` entries from a `SchemaNode` tree by collecting
250
- * all importable `ref` nodes. A `$ref` is considered importable when it resolves
251
- * to a known component in the OAS spec (`oas.get($ref)` is truthy).
196
+ * Map from original `$ref` paths to their collision-resolved schema names.
197
+ * e.g. `'#/components/schemas/Order'` `'OrderSchema'`
252
198
  *
253
- * The `resolve` callback is called with the schema name (last segment of the
254
- * `$ref`, collision-corrected via the OAS name mapping) and must return the
255
- * `{ name, path }` pair for the generated import, or `undefined` to skip it.
256
- *
257
- * @example
258
- * ```ts
259
- * const imports = parser.getImports(schemaNode, (schemaName) => ({
260
- * name: schemaManager.getName(schemaName, { type: 'type' }),
261
- * path: schemaManager.getFile(schemaName).path,
262
- * }))
263
- * ```
199
+ * Pass this to the standalone `getImports()` to resolve imports without holding
200
+ * a reference to the full parser or OAS instance.
264
201
  */
265
- getImports: (node: SchemaNode, resolve: (schemaName: string) => { name: string; path: string } | undefined) => Array<KubbFile.Import>
266
- }
267
-
268
- /**
269
- * When a discriminator is present, replaces the discriminator property's schema with
270
- * an enum of the mapping keys so downstream code emits a precise literal-union type.
271
- * Returns the original schema unchanged when there is no discriminator or no mapping.
272
- */
273
- function applyDiscriminatorEnum(schema: SchemaObject): SchemaObject {
274
- if (!isDiscriminator(schema)) return schema
275
- const propName = schema.discriminator.propertyName
276
- if (!schema.properties?.[propName]) return schema
277
- return {
278
- ...schema,
279
- properties: {
280
- ...schema.properties,
281
- [propName]: {
282
- ...(schema.properties[propName] as SchemaObject),
283
- enum: schema.discriminator.mapping ? Object.keys(schema.discriminator.mapping) : undefined,
284
- },
285
- },
286
- } as SchemaObject
202
+ nameMapping: Map<string, string>
287
203
  }
288
204
 
289
205
  /**
@@ -310,6 +226,14 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
310
226
  // e.g., { '#/components/schemas/Order': 'OrderSchema', '#/components/responses/Product': 'ProductResponse' }
311
227
  const { schemas: schemaObjects, nameMapping } = oas.getSchemas({ contentType, collisionDetection })
312
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> = {}
232
+
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
236
+
313
237
  /**
314
238
  * Maps an `'any' | 'unknown' | 'void'` option string to the corresponding `SchemaType` constant.
315
239
  * Used for both `unknownType` (unannotated schemas) and `emptySchemaType` (empty `{}` schemas).
@@ -325,7 +249,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
325
249
  * Returns `undefined` when `dateType` is `false`, meaning the format should fall through to `string`.
326
250
  */
327
251
  function getDateType(
328
- options: Options,
252
+ options: ParserOptions,
329
253
  format: 'date-time' | 'date' | 'time',
330
254
  ): { type: 'datetime'; offset?: boolean; local?: boolean } | { type: 'date' | 'time'; representation: 'date' | 'string' } | undefined {
331
255
  if (!options.dateType) {
@@ -357,7 +281,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
357
281
  * Shared metadata fields included in every `createSchema` call.
358
282
  * Centralizes the common properties so sub-handlers don't repeat them.
359
283
  */
360
- function buildSchemaBase(schema: SchemaObject, name: string | undefined, nullable: true | undefined, defaultValue: unknown) {
284
+ function renderSchemaBase(schema: SchemaObject, name: string | undefined, nullable: true | undefined, defaultValue: unknown) {
361
285
  return {
362
286
  name,
363
287
  nullable,
@@ -383,18 +307,17 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
383
307
  * reflected in generated JSDoc and type modifiers.
384
308
  */
385
309
  function convertRef({ schema, nullable, defaultValue }: SchemaContext): SchemaNode {
386
- const schemaObject = schema as unknown as SchemaObject & { $ref: string }
387
310
  return createSchema({
388
311
  type: 'ref',
389
- name: extractRefName(schemaObject.$ref),
390
- ref: schemaObject.$ref,
312
+ name: extractRefName(schema.$ref!),
313
+ ref: schema.$ref,
391
314
  nullable,
392
- description: schemaObject.description,
393
- deprecated: schemaObject.deprecated,
394
- readOnly: schemaObject.readOnly,
395
- writeOnly: schemaObject.writeOnly,
396
- pattern: schemaObject.type === 'string' ? schemaObject.pattern : undefined,
397
- example: schemaObject.example,
315
+ description: schema.description,
316
+ deprecated: schema.deprecated,
317
+ readOnly: schema.readOnly,
318
+ writeOnly: schema.writeOnly,
319
+ pattern: schema.type === 'string' ? schema.pattern : undefined,
320
+ example: schema.example,
398
321
  default: defaultValue,
399
322
  })
400
323
  }
@@ -421,8 +344,8 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
421
344
  !(Array.isArray(schema.required) && schema.required.length) &&
422
345
  schema.additionalProperties === undefined
423
346
  ) {
424
- const [memberSchema] = schema.allOf as SchemaObject[]
425
- const memberNode = convertSchema({ schema: memberSchema! }, options)
347
+ const [memberSchema] = schema.allOf as Array<SchemaObject | ReferenceObject>
348
+ const memberNode = convertSchema({ schema: memberSchema! as SchemaObject }, options)
426
349
  const { kind: _kind, ...memberNodeProps } = memberNode
427
350
  const mergedNullable = nullable || memberNode.nullable || undefined
428
351
  const mergedDefault = schema.default === null && mergedNullable ? undefined : (schema.default ?? memberNode.default)
@@ -444,22 +367,24 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
444
367
 
445
368
  // When a child schema extends a discriminator parent via allOf and the parent's oneOf/anyOf
446
369
  // references that child back, skip that allOf item to prevent a circular type reference.
447
- const allOfMembers: SchemaNode[] = (schema.allOf as SchemaObject[])
370
+ const allOfMembers: Array<SchemaNode> = (schema.allOf as Array<SchemaObject | ReferenceObject>)
448
371
  .filter((item) => {
449
372
  if (!isReference(item) || !name) return true
450
- const deref = oas.get<SchemaObject>((item as { $ref: string }).$ref)
373
+ const deref = oas.get<SchemaObject>(item.$ref)
451
374
  if (!deref || !isDiscriminator(deref)) return true
452
- const parentUnion = (deref as SchemaObject).oneOf ?? (deref as SchemaObject).anyOf
375
+ const parentUnion = deref.oneOf ?? deref.anyOf
453
376
  if (!parentUnion) return true
454
377
  const childRef = `#/components/schemas/${name}`
455
- const inOneOf = parentUnion.some((oneOfItem) => isReference(oneOfItem) && (oneOfItem as { $ref: string }).$ref === childRef)
456
- const inMapping = Object.values((deref as SchemaObject & { discriminator: { mapping?: Record<string, string> } }).discriminator.mapping ?? {}).some(
457
- (v) => v === childRef,
458
- )
378
+ const inOneOf = parentUnion.some((oneOfItem) => isReference(oneOfItem) && oneOfItem.$ref === childRef)
379
+ const inMapping = Object.values(deref.discriminator.mapping ?? {}).some((v) => v === childRef)
459
380
  return !inOneOf && !inMapping
460
381
  })
461
382
  .map((s) => convertSchema({ schema: s as SchemaObject }, options))
462
383
 
384
+ // Track where allOf-derived members end so only the synthetic members added below
385
+ // (injected required-key objects + outer-properties object) are candidates for merging.
386
+ const syntheticStart = allOfMembers.length
387
+
463
388
  // When `required` lists keys not present in the outer `properties`, resolve them from
464
389
  // the allOf member schemas and inject them as extra intersection members.
465
390
  if (Array.isArray(schema.required) && schema.required.length) {
@@ -467,10 +392,10 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
467
392
  const missingRequired = schema.required.filter((key) => !outerKeys.has(key))
468
393
 
469
394
  if (missingRequired.length) {
470
- const resolvedMembers = (schema.allOf as SchemaObject[]).flatMap((item) => {
471
- if (!isReference(item)) return [item]
395
+ const resolvedMembers = (schema.allOf as Array<SchemaObject | ReferenceObject>).flatMap((item) => {
396
+ if (!isReference(item)) return [item as SchemaObject]
472
397
  const deref = oas.get<SchemaObject>(item.$ref)
473
- return deref && !isReference(deref) ? [deref as SchemaObject] : []
398
+ return deref && !isReference(deref) ? [deref] : []
474
399
  })
475
400
 
476
401
  for (const key of missingRequired) {
@@ -485,14 +410,15 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
485
410
  }
486
411
 
487
412
  if (schema.properties) {
488
- const { allOf: _allOf, ...schemaWithoutAllOf } = schema as SchemaObject & { allOf?: unknown[] }
489
- allOfMembers.push(convertSchema({ schema: schemaWithoutAllOf as SchemaObject }, options))
413
+ const { allOf: _allOf, ...schemaWithoutAllOf } = schema
414
+ allOfMembers.push(convertSchema({ schema: schemaWithoutAllOf }, options))
490
415
  }
491
416
 
417
+ // Merge consecutive anonymous object members within the synthetic portion — see `mergeAdjacentAnonymousObjects`.
492
418
  return createSchema({
493
419
  type: 'intersection',
494
- members: allOfMembers,
495
- ...buildSchemaBase(schema, name, nullable, defaultValue),
420
+ members: [...allOfMembers.slice(0, syntheticStart), ...mergeAdjacentAnonymousObjects(allOfMembers.slice(syntheticStart))],
421
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
496
422
  })
497
423
  }
498
424
 
@@ -507,30 +433,48 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
507
433
  function convertUnion({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
508
434
  const unionMembers = [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]
509
435
  const unionBase = {
510
- ...buildSchemaBase(schema, name, nullable, defaultValue),
436
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
511
437
  discriminatorPropertyName: isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
512
438
  }
513
439
 
514
440
  if (schema.properties) {
515
- const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema as SchemaObject & { oneOf?: unknown[]; anyOf?: unknown[] }
516
- const propertiesNode = convertSchema({ schema: schemaWithoutUnion as SchemaObject }, options)
441
+ const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
442
+ const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
443
+
444
+ // Strip discriminator so convertObject won't re-apply the full mapping enum.
445
+ const memberBaseSchema: SchemaObject = discriminator
446
+ ? (Object.fromEntries(Object.entries(schemaWithoutUnion).filter(([key]) => key !== 'discriminator')) as SchemaObject)
447
+ : schemaWithoutUnion
448
+
449
+ // Convert shared properties once to avoid duplicate enum naming
450
+ // (e.g. StatusEnum appearing twice and getting a numeric suffix).
451
+ const sharedPropertiesNode = convertSchema({ schema: memberBaseSchema, name: isLegacyNaming ? undefined : name }, options)
517
452
 
518
453
  return createSchema({
519
454
  type: 'union',
520
455
  ...unionBase,
521
- members: unionMembers.map((s) =>
522
- createSchema({
456
+ members: unionMembers.map((s) => {
457
+ const ref = isReference(s) ? s.$ref : undefined
458
+ const discriminatorValue = discriminator?.mapping && ref ? Object.entries(discriminator.mapping).find(([, v]) => v === ref)?.[0] : undefined
459
+
460
+ let propertiesNode = sharedPropertiesNode
461
+
462
+ if (discriminatorValue && discriminator) {
463
+ propertiesNode = applyDiscriminatorEnum({ node: propertiesNode, propertyName: discriminator.propertyName, values: [discriminatorValue] })
464
+ }
465
+
466
+ return createSchema({
523
467
  type: 'intersection',
524
468
  members: [convertSchema({ schema: s as SchemaObject }, options), propertiesNode],
525
- }),
526
- ),
469
+ })
470
+ }),
527
471
  })
528
472
  }
529
473
 
530
474
  return createSchema({
531
475
  type: 'union',
532
476
  ...unionBase,
533
- members: unionMembers.map((s) => convertSchema({ schema: s as SchemaObject }, options)),
477
+ members: simplifyUnionMembers(unionMembers.map((s) => convertSchema({ schema: s as SchemaObject }, options))),
534
478
  })
535
479
  }
536
480
 
@@ -559,7 +503,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
559
503
  type: 'enum',
560
504
  primitive: constPrimitive,
561
505
  enumValues: [constValue as string | number | boolean],
562
- ...buildSchemaBase(schema, name, nullable, defaultValue),
506
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
563
507
  })
564
508
  }
565
509
 
@@ -569,7 +513,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
569
513
  * (i.e. `format: 'date-time'` with `dateType: false`).
570
514
  */
571
515
  function convertFormat({ schema, name, nullable, defaultValue, mergedOptions }: SchemaContext): SchemaNode | undefined {
572
- const base = buildSchemaBase(schema, name, nullable, defaultValue)
516
+ const base = renderSchemaBase(schema, name, nullable, defaultValue)
573
517
 
574
518
  // int64 is option-dependent so it can't live in the static formatMap.
575
519
  if (schema.format === 'int64') {
@@ -603,6 +547,10 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
603
547
  if (specialType === 'number' || specialType === 'integer' || specialType === 'bigint') {
604
548
  return createSchema({ ...base, primitive: specialPrimitive, type: specialType })
605
549
  }
550
+ if (specialType === 'url') {
551
+ return createSchema({ ...base, primitive: 'string' as const, type: 'url' })
552
+ }
553
+
606
554
  return createSchema({ ...base, primitive: specialPrimitive, type: specialType as ScalarSchemaType })
607
555
  }
608
556
 
@@ -619,10 +567,9 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
619
567
  function convertEnum({ schema, name, nullable, type, options }: SchemaContext): SchemaNode {
620
568
  // Malformed schema: `{ type: 'array', enum: [...] }` — normalize by moving the enum into items.
621
569
  if (type === 'array') {
622
- const rawSchema = schema as unknown as { items?: SchemaObject; enum?: unknown[] }
623
- const isItemsObject = typeof rawSchema.items === 'object' && !Array.isArray(rawSchema.items)
624
- const normalizedItems = { ...(isItemsObject ? rawSchema.items : {}), enum: schema.enum } as SchemaObject
625
- const { enum: _enum, ...schemaWithoutEnum } = schema as SchemaObject & { enum?: unknown[] }
570
+ const isItemsObject = typeof schema.items === 'object' && !Array.isArray(schema.items)
571
+ const normalizedItems: SchemaObject = { ...(isItemsObject ? (schema.items as SchemaObject) : {}), enum: schema.enum }
572
+ const { enum: _enum, ...schemaWithoutEnum } = schema
626
573
  return convertSchema({ schema: { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject, name }, options)
627
574
  }
628
575
 
@@ -715,22 +662,69 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
715
662
  * - not required + not nullable → `optional: true`
716
663
  * - not required + nullable → `nullish: true`
717
664
  */
718
- function convertObject({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
719
- // When a discriminator is present, override the discriminator property's schema to use
720
- // an enum of the mapping keys for a precise literal-union type.
721
- const resolvedSchema = applyDiscriminatorEnum(schema)
665
+ /**
666
+ * Builds the propagation name for a child property during recursive schema conversion.
667
+ *
668
+ * - **Legacy naming** (`isLegacyNaming`): only the immediate property key is used
669
+ * (e.g. `Params` for property `params`), keeping nested names short.
670
+ * - **Default naming**: the parent name is prepended so the full path is encoded
671
+ * (e.g. `OrderParams` when parent is `Order`).
672
+ */
673
+ function resolveChildName(parentName: string | undefined, propName: string): string | undefined {
674
+ if (isLegacyNaming) {
675
+ return pascalCase(propName)
676
+ }
677
+ return parentName ? pascalCase([parentName, propName].join(' ')) : undefined
678
+ }
679
+
680
+ /**
681
+ * Derives the final name for an enum property schema node.
682
+ *
683
+ * The raw name always includes the enum suffix (e.g. `StatusEnum`).
684
+ * In legacy mode an additional deduplication step appends a numeric suffix
685
+ * when the same name has already been used (e.g. `ParamsStatusEnum2`).
686
+ */
687
+ function resolveEnumPropName(parentName: string | undefined, propName: string, enumSuffix: string): string {
688
+ const raw = pascalCase([parentName, propName, enumSuffix].filter(Boolean).join(' '))
689
+ return isLegacyNaming ? getUniqueName(raw, usedEnumNames) : raw
690
+ }
691
+
692
+ /**
693
+ * Given a freshly-converted property schema, returns the node with a correct
694
+ * `name` attached — or stripped — depending on whether the node is a named
695
+ * enum, a boolean const-enum (always inlined), or a regular schema.
696
+ */
697
+ function applyEnumName(propNode: SchemaNode, parentName: string | undefined, propName: string, enumSuffix: string): SchemaNode {
698
+ const enumNode = narrowSchema(propNode, 'enum')
722
699
 
723
- const properties: Array<PropertyNode> = resolvedSchema.properties
724
- ? Object.entries(resolvedSchema.properties).map(([propName, propSchema]) => {
725
- const required = Array.isArray(resolvedSchema.required) ? resolvedSchema.required.includes(propName) : !!resolvedSchema.required
700
+ // Boolean-primitive enum nodes (e.g. `const: false`) are always inlined as
701
+ // literal types and must not receive a named identifier.
702
+ if (enumNode?.primitive === 'boolean') {
703
+ return { ...propNode, name: undefined }
704
+ }
705
+
706
+ if (enumNode) {
707
+ return { ...propNode, name: resolveEnumPropName(parentName, propName, enumSuffix) }
708
+ }
709
+
710
+ return propNode
711
+ }
712
+
713
+ function convertObject({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
714
+ const properties: Array<PropertyNode> = schema.properties
715
+ ? Object.entries(schema.properties).map(([propName, propSchema]) => {
716
+ const required = Array.isArray(schema.required) ? schema.required.includes(propName) : !!schema.required
726
717
  const resolvedPropSchema = propSchema as SchemaObject
727
718
  const propNullable = isNullable(resolvedPropSchema)
728
- const derivedPropName = name ? pascalCase([name, propName, mergedOptions.enumSuffix].filter(Boolean).join(' ')) : undefined
719
+
720
+ const childName = resolveChildName(name, propName)
721
+ const propNode = convertSchema({ schema: resolvedPropSchema, name: childName }, options)
722
+ const schemaNode = applyEnumName(propNode, name, propName, mergedOptions.enumSuffix)
729
723
 
730
724
  return createProperty({
731
725
  name: propName,
732
726
  schema: {
733
- ...convertSchema({ schema: resolvedPropSchema, name: derivedPropName }, options),
727
+ ...schemaNode,
734
728
  nullable: propNullable || undefined,
735
729
  optional: !required && !propNullable ? true : undefined,
736
730
  nullish: !required && propNullable ? true : undefined,
@@ -740,7 +734,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
740
734
  })
741
735
  : []
742
736
 
743
- const additionalProperties = resolvedSchema.additionalProperties
737
+ const additionalProperties = schema.additionalProperties
744
738
  let additionalPropertiesNode: SchemaNode | true | undefined
745
739
  if (additionalProperties === true) {
746
740
  additionalPropertiesNode = true
@@ -752,28 +746,38 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
752
746
  additionalPropertiesNode = createSchema({ type: resolveTypeOption(mergedOptions.unknownType) })
753
747
  }
754
748
 
755
- const rawPatternProperties =
756
- 'patternProperties' in resolvedSchema ? (resolvedSchema as unknown as { patternProperties?: Record<string, SchemaObject> }).patternProperties : undefined
749
+ const rawPatternProperties = 'patternProperties' in schema ? schema.patternProperties : undefined
757
750
 
758
751
  const patternProperties = rawPatternProperties
759
752
  ? Object.fromEntries(
760
753
  Object.entries(rawPatternProperties).map(([pattern, patternSchema]) => [
761
754
  pattern,
762
- (patternSchema as unknown) === true || Object.keys(patternSchema as object).length === 0
755
+ patternSchema === true || (typeof patternSchema === 'object' && Object.keys(patternSchema).length === 0)
763
756
  ? createSchema({ type: resolveTypeOption(mergedOptions.unknownType) })
764
757
  : convertSchema({ schema: patternSchema as SchemaObject }, options),
765
758
  ]),
766
759
  )
767
760
  : undefined
768
761
 
769
- return createSchema({
762
+ const objectNode: SchemaNode = createSchema({
770
763
  type: 'object',
771
764
  primitive: 'object',
772
765
  properties,
773
766
  additionalProperties: additionalPropertiesNode,
774
767
  patternProperties,
775
- ...buildSchemaBase(schema, name, nullable, defaultValue),
768
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
776
769
  })
770
+
771
+ // When a discriminator is present, replace the discriminator property's schema
772
+ // with an enum of the mapping keys for a precise literal-union type.
773
+ if (isDiscriminator(schema) && schema.discriminator.mapping) {
774
+ const discPropName = schema.discriminator.propertyName
775
+ const values = Object.keys(schema.discriminator.mapping)
776
+ const enumName = name ? resolveEnumPropName(name, discPropName, mergedOptions.enumSuffix) : undefined
777
+ return applyDiscriminatorEnum({ node: objectNode, propertyName: discPropName, values, enumName })
778
+ }
779
+
780
+ return objectNode
777
781
  }
778
782
 
779
783
  /**
@@ -783,9 +787,8 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
783
787
  * after the prefix items is mapped to the rest parameter of the tuple.
784
788
  */
785
789
  function convertTuple({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
786
- const rawSchema = schema as unknown as { prefixItems: SchemaObject[]; items?: SchemaObject }
787
- const tupleItems = rawSchema.prefixItems.map((item) => convertSchema({ schema: item }, options))
788
- const rest = rawSchema.items ? convertSchema({ schema: rawSchema.items }, options) : undefined
790
+ const tupleItems = (schema.prefixItems ?? []).map((item) => convertSchema({ schema: item as SchemaObject }, options))
791
+ const rest = schema.items ? convertSchema({ schema: schema.items as SchemaObject }, options) : undefined
789
792
 
790
793
  return createSchema({
791
794
  type: 'tuple',
@@ -794,7 +797,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
794
797
  rest,
795
798
  min: schema.minItems,
796
799
  max: schema.maxItems,
797
- ...buildSchemaBase(schema, name, nullable, defaultValue),
800
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
798
801
  })
799
802
  }
800
803
 
@@ -805,12 +808,11 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
805
808
  * `enumSuffix` is forwarded so generators can emit a named enum declaration.
806
809
  */
807
810
  function convertArray({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
808
- const rawSchema = schema as unknown as { items?: SchemaObject }
809
- // When the array items schema contains an inline enum, derive a name from the parent
810
- // array's name + enumSuffix so generators can emit a named enum declaration.
811
- const rawItems = rawSchema.items as SchemaObject | undefined
812
- const itemName = rawItems?.enum?.length && name ? pascalCase([name, mergedOptions.enumSuffix].join(' ')) : undefined
813
- const items = rawSchema.items ? [convertSchema({ schema: rawSchema.items, name: itemName }, options)] : []
811
+ const rawItems = schema.items as SchemaObject | undefined
812
+ // When the items schema contains an inline enum, derive a named identifier
813
+ // so generators can emit a standalone enum declaration.
814
+ const itemName = rawItems?.enum?.length && name ? resolveEnumPropName(undefined, name, mergedOptions.enumSuffix) : undefined
815
+ const items = rawItems ? [convertSchema({ schema: rawItems, name: itemName }, options)] : []
814
816
 
815
817
  return createSchema({
816
818
  type: 'array',
@@ -819,7 +821,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
819
821
  min: schema.minItems,
820
822
  max: schema.maxItems,
821
823
  unique: schema.uniqueItems ?? undefined,
822
- ...buildSchemaBase(schema, name, nullable, defaultValue),
824
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
823
825
  })
824
826
  }
825
827
 
@@ -833,7 +835,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
833
835
  min: schema.minLength,
834
836
  max: schema.maxLength,
835
837
  pattern: schema.pattern,
836
- ...buildSchemaBase(schema, name, nullable, defaultValue),
838
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
837
839
  })
838
840
  }
839
841
 
@@ -848,7 +850,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
848
850
  max: schema.maximum,
849
851
  exclusiveMinimum: typeof schema.exclusiveMinimum === 'number' ? schema.exclusiveMinimum : undefined,
850
852
  exclusiveMaximum: typeof schema.exclusiveMaximum === 'number' ? schema.exclusiveMaximum : undefined,
851
- ...buildSchemaBase(schema, name, nullable, defaultValue),
853
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
852
854
  })
853
855
  }
854
856
 
@@ -859,7 +861,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
859
861
  return createSchema({
860
862
  type: 'boolean',
861
863
  primitive: 'boolean',
862
- ...buildSchemaBase(schema, name, nullable, defaultValue),
864
+ ...renderSchemaBase(schema, name, nullable, defaultValue),
863
865
  })
864
866
  }
865
867
 
@@ -894,12 +896,12 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
894
896
  * 10. Object / array / tuple / scalar by `type`
895
897
  * 11. Empty schema fallback (`emptySchemaType` option)
896
898
  */
897
- function convertSchema({ schema, name }: { schema: SchemaObject; name?: string }, options?: Partial<Options>): SchemaNode {
898
- const mergedOptions: Options = { ...DEFAULT_OPTIONS, ...options }
899
+ function convertSchema({ schema, name }: { schema: SchemaObject; name?: string }, options?: Partial<ParserOptions>): SchemaNode {
900
+ const mergedOptions: ParserOptions = { ...DEFAULT_PARSER_OPTIONS, ...options }
899
901
  // Flatten keyword-only allOf fragments (no $ref, no structural keys) into the parent
900
902
  // schema before parsing, so simple annotation patterns don't produce needless intersections.
901
- const flattenedSchema = flattenSchema(schema as unknown as Parameters<typeof flattenSchema>[0]) as SchemaObject | null
902
- if (flattenedSchema && flattenedSchema !== (schema as unknown)) {
903
+ const flattenedSchema = flattenSchema(schema)
904
+ if (flattenedSchema && flattenedSchema !== schema) {
903
905
  return convertSchema({ schema: flattenedSchema, name }, options)
904
906
  }
905
907
 
@@ -933,8 +935,8 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
933
935
  }
934
936
 
935
937
  // OAS 3.1: `contentMediaType: 'application/octet-stream'` on a string schema signals binary data.
936
- if (schema.type === 'string' && (schema as SchemaObject & { contentMediaType?: string }).contentMediaType === 'application/octet-stream') {
937
- return createSchema({ type: 'blob', primitive: 'string', ...buildSchemaBase(schema, name, nullable, defaultValue) })
938
+ if (schema.type === 'string' && schema.contentMediaType === 'application/octet-stream') {
939
+ return createSchema({ type: 'blob', primitive: 'string', ...renderSchemaBase(schema, name, nullable, defaultValue) })
938
940
  }
939
941
 
940
942
  // OAS 3.1: `type` may be an array — e.g. `["string", "integer", "null"]`.
@@ -947,8 +949,8 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
947
949
  if (nonNullTypes.length > 1) {
948
950
  return createSchema({
949
951
  type: 'union',
950
- members: nonNullTypes.map((t) => convertSchema({ schema: { ...schema, type: t } as SchemaObject }, options)),
951
- ...buildSchemaBase(schema, name, arrayNullable, defaultValue),
952
+ members: nonNullTypes.map((t) => convertSchema({ schema: { ...schema, type: t } as SchemaObject, name }, options)),
953
+ ...renderSchemaBase(schema, name, arrayNullable, defaultValue),
952
954
  })
953
955
  }
954
956
  }
@@ -983,17 +985,23 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
983
985
  * Converts a single dereferenced OAS parameter object into a `ParameterNode`.
984
986
  * When the parameter has no `schema` or its schema is a `$ref`, falls back to `unknownType`.
985
987
  */
986
- function parseParameter(options: Options, param: Record<string, unknown>): ParameterNode {
987
- const schema =
988
- param['schema'] && !isReference(param['schema'] as object)
988
+ function parseParameter(options: ParserOptions, param: Record<string, unknown>): ParameterNode {
989
+ const required = (param['required'] as boolean | undefined) ?? false
990
+
991
+ const schema: SchemaNode =
992
+ param['schema'] && !isReference(param['schema'])
989
993
  ? convertSchema({ schema: param['schema'] as SchemaObject }, options)
990
994
  : createSchema({ type: resolveTypeOption(options.unknownType) })
991
995
 
992
996
  return createParameter({
993
997
  name: param['name'] as string,
994
998
  in: param['in'] as ParameterLocation,
995
- schema,
996
- required: (param['required'] as boolean | undefined) ?? false,
999
+ schema: {
1000
+ ...schema,
1001
+ description: (param['description'] as string | undefined) ?? schema.description,
1002
+ optional: !required || !!schema.optional ? true : undefined,
1003
+ },
1004
+ required,
997
1005
  })
998
1006
  }
999
1007
 
@@ -1001,12 +1009,8 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1001
1009
  * Converts an OAS `Operation` into an `OperationNode`, resolving parameters,
1002
1010
  * request body, and all response codes into their AST node equivalents.
1003
1011
  */
1004
- function parseOperation(options: Options, oas: Oas, operation: Operation): OperationNode {
1005
- const parameters: Array<ParameterNode> = operation.getParameters().map((param) => {
1006
- const dereferenced = oas.dereferenceWithRef(param) as unknown as Record<string, unknown>
1007
-
1008
- return parseParameter(options, dereferenced)
1009
- })
1012
+ function parseOperation(options: ParserOptions, oas: Oas, operation: Operation): OperationNode {
1013
+ const parameters: Array<ParameterNode> = oas.getParameters(operation).map((param) => parseParameter(options, param as unknown as Record<string, unknown>))
1010
1014
 
1011
1015
  const requestBodySchema = oas.getRequestSchema(operation)
1012
1016
  const requestBody = requestBodySchema ? convertSchema({ schema: requestBodySchema }, options) : undefined
@@ -1015,7 +1019,10 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1015
1019
  const responseObj = operation.getResponseByStatusCode(statusCode)
1016
1020
  const responseSchema = oas.getResponseSchema(operation, statusCode)
1017
1021
 
1018
- const schema = responseSchema && Object.keys(responseSchema).length > 0 ? convertSchema({ schema: responseSchema }, options) : undefined
1022
+ const schema =
1023
+ responseSchema && Object.keys(responseSchema).length > 0
1024
+ ? convertSchema({ schema: responseSchema }, options)
1025
+ : createSchema({ type: resolveTypeOption(options.emptySchemaType) })
1019
1026
 
1020
1027
  const description = typeof responseObj === 'object' && responseObj !== null && !Array.isArray(responseObj) ? responseObj.description : undefined
1021
1028
 
@@ -1037,7 +1044,7 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1037
1044
  return createOperation({
1038
1045
  operationId: operation.getOperationId(),
1039
1046
  method: operation.method.toUpperCase() as HttpMethod,
1040
- path: operation.path,
1047
+ path: new URLPath(operation.path).URL,
1041
1048
  tags: operation.getTags().map((tag) => tag.name),
1042
1049
  summary: operation.getSummary() || undefined,
1043
1050
  description: operation.getDescription() || undefined,
@@ -1052,8 +1059,8 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1052
1059
  * Converts an OpenAPI/Swagger spec (wrapped in a Kubb `Oas` instance) into
1053
1060
  * a `RootNode` — the top-level node of the `@kubb/ast` tree.
1054
1061
  */
1055
- function parse<TOptions extends Partial<Options> = object>(options?: TOptions): RootNode {
1056
- const mergedOptions: Options = { ...DEFAULT_OPTIONS, ...options }
1062
+ function parse<TOptions extends Partial<ParserOptions> = object>(options?: TOptions): RootNode {
1063
+ const mergedOptions: ParserOptions = { ...DEFAULT_PARSER_OPTIONS, ...options }
1057
1064
 
1058
1065
  const schemas: Array<SchemaNode> = Object.entries(schemaObjects).map(([name, schemaObject]) =>
1059
1066
  convertSchema({ schema: schemaObject as SchemaObject, name }, mergedOptions),
@@ -1101,36 +1108,10 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1101
1108
  }) as SchemaNode
1102
1109
  }
1103
1110
 
1104
- /**
1105
- * Collects all `KubbFile.Import` descriptors needed by a `SchemaNode` tree.
1106
- *
1107
- * Walks the tree looking for `ref` nodes, verifies each `$ref` is resolvable in the spec,
1108
- * applies collision-resolved names from `nameMapping`, and calls `resolve` to obtain the
1109
- * import path and name. Returns an empty array for refs that cannot be resolved.
1110
- */
1111
- function getImports(node: SchemaNode, resolve: (schemaName: string) => { name: string; path: string } | undefined): Array<KubbFile.Import> {
1112
- return collect<KubbFile.Import>(node, {
1113
- schema(schemaNode): KubbFile.Import | undefined {
1114
- if (schemaNode.type !== 'ref' || !schemaNode.ref) return
1115
- // Use the OAS instance to verify this $ref is importable (exists in the spec).
1116
- if (!oas.get(schemaNode.ref)) return
1117
-
1118
- const rawName = extractRefName(schemaNode.ref)
1119
-
1120
- // Apply collision-resolved name if available.
1121
- const schemaName = nameMapping.get(rawName) ?? rawName
1122
- const result = resolve(schemaName)
1123
- if (!result) return
1124
-
1125
- return { name: [result.name], path: result.path }
1126
- },
1127
- })
1128
- }
1129
-
1130
1111
  return {
1131
- parse: parse,
1112
+ parse,
1132
1113
  convertSchema,
1133
1114
  resolveRefs,
1134
- getImports,
1115
+ nameMapping,
1135
1116
  } as OasParser
1136
1117
  }