@kubb/adapter-oas 5.0.0-alpha.7 → 5.0.0-alpha.71

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,189 +1,33 @@
1
- import { pascalCase, URLPath } from '@internals/utils'
2
- import { createOperation, createParameter, createProperty, createResponse, createRoot, createSchema, narrowSchema, schemaTypes, transform } from '@kubb/ast'
3
- import type {
4
- ArraySchemaNode,
5
- DateSchemaNode,
6
- DatetimeSchemaNode,
7
- EnumSchemaNode,
8
- HttpMethod,
9
- IntersectionSchemaNode,
10
- MediaType,
11
- NumberSchemaNode,
12
- ObjectSchemaNode,
13
- OperationNode,
14
- ParameterLocation,
15
- ParameterNode,
16
- PrimitiveSchemaType,
17
- PropertyNode,
18
- RefSchemaNode,
19
- ResponseNode,
20
- RootNode,
21
- ScalarSchemaNode,
22
- ScalarSchemaType,
23
- SchemaNode,
24
- SchemaType,
25
- StatusCode,
26
- StringSchemaNode,
27
- TimeSchemaNode,
28
- UnionSchemaNode,
29
- } from '@kubb/ast/types'
30
- import { enumExtensionKeys, formatMap, knownMediaTypes } from './constants.ts'
31
- import type { Oas } from './oas/Oas.ts'
32
- import type { contentType, Operation, ReferenceObject, SchemaObject } from './oas/types.ts'
33
- import { flattenSchema, isDiscriminator, isNullable, isReference } from './oas/utils.ts'
34
- import { applyDiscriminatorEnum, extractRefName, mergeAdjacentAnonymousObjects, simplifyUnionMembers } from './utils.ts'
1
+ import { URLPath } from '@internals/utils'
2
+ import { ast } from '@kubb/core'
3
+ import BaseOas from 'oas'
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'
7
+ import {
8
+ buildSchemaNode,
9
+ flattenSchema,
10
+ getDateType,
11
+ getMediaType,
12
+ getParameters,
13
+ getPrimitiveType,
14
+ getRequestBodyContentTypes,
15
+ getRequestSchema,
16
+ getResponseSchema,
17
+ getSchemas,
18
+ getSchemaType,
19
+ } from './resolvers.ts'
20
+ import type { ContentType, Document, Operation, ReferenceObject, SchemaObject } from './types.ts'
35
21
 
36
22
  /**
37
- * Distributive `Omit` correctly distributes over union types so that
38
- * `Omit<A | B, 'kind'>` produces `Omit<A, 'kind'> | Omit<B, 'kind'>`
39
- * rather than `Omit<A | B, 'kind'>`.
40
- */
41
- type DistributiveOmit<TValue, TKey extends PropertyKey> = TValue extends unknown ? Omit<TValue, TKey> : never
42
-
43
- /**
44
- * Maps each `dateType` option value to the AST node produced by `format: 'date-time'`.
45
- */
46
- type DateTimeNodeByDateType = {
47
- date: DateSchemaNode
48
- string: DatetimeSchemaNode
49
- stringOffset: DatetimeSchemaNode
50
- stringLocal: DatetimeSchemaNode
51
- false: StringSchemaNode
52
- }
53
-
54
- /**
55
- * Resolves the AST node produced by `format: 'date-time'` based on the `dateType` option.
56
- */
57
- type ResolveDateTimeNode<TDateType extends Options['dateType']> = DateTimeNodeByDateType[TDateType extends keyof DateTimeNodeByDateType ? TDateType : 'string']
58
-
59
- /**
60
- * Single source of truth: ordered list of `[shape, SchemaNode]` pairs.
61
- * `InferSchemaNode` walks this tuple in order and returns the node type of the first matching entry.
62
- * Parameterized over `TDateType` so `format: 'date-time'` resolves to the correct node based on the option.
63
- */
64
- type SchemaNodeMap<TDateType extends Options['dateType'] = Options['dateType']> = [
65
- [{ $ref: string }, RefSchemaNode],
66
- // allOf with sibling `properties` always produces an intersection (shared props are appended as a member).
67
- [{ allOf: ReadonlyArray<unknown>; properties: object }, IntersectionSchemaNode],
68
- // allOf with 2+ members always produces an intersection.
69
- [{ allOf: readonly [unknown, unknown, ...unknown[]] }, IntersectionSchemaNode],
70
- // Single-member allOf without sibling `properties` flattens to the member type.
71
- [{ allOf: ReadonlyArray<unknown> }, SchemaNode],
72
- [{ oneOf: ReadonlyArray<unknown> }, UnionSchemaNode],
73
- [{ anyOf: ReadonlyArray<unknown> }, UnionSchemaNode],
74
- [{ const: null }, ScalarSchemaNode],
75
- [{ const: string | number | boolean }, EnumSchemaNode],
76
- // OAS 3.1 multi-type array: `{ type: ['string', 'integer'] }` → union node.
77
- [{ type: ReadonlyArray<string> }, UnionSchemaNode],
78
- // `{ type: 'array', enum }` is normalized at runtime: enum moves into items → array node.
79
- [{ type: 'array'; enum: ReadonlyArray<unknown> }, ArraySchemaNode],
80
- [{ enum: ReadonlyArray<unknown> }, EnumSchemaNode],
81
- [{ type: 'object' }, ObjectSchemaNode],
82
- [{ additionalProperties: boolean | {} }, ObjectSchemaNode],
83
- [{ type: 'array' }, ArraySchemaNode],
84
- [{ items: object }, ArraySchemaNode],
85
- [{ prefixItems: ReadonlyArray<unknown> }, ArraySchemaNode],
86
- // Format entries with explicit type — placed before generic type entries so format wins.
87
- [{ type: string; format: 'date-time' }, ResolveDateTimeNode<TDateType>],
88
- [{ type: string; format: 'date' }, DateSchemaNode],
89
- [{ type: string; format: 'time' }, TimeSchemaNode],
90
- [{ format: 'date-time' }, ResolveDateTimeNode<TDateType>],
91
- [{ format: 'date' }, DateSchemaNode],
92
- [{ format: 'time' }, TimeSchemaNode],
93
- [{ type: 'string' }, StringSchemaNode],
94
- [{ type: 'number' }, NumberSchemaNode],
95
- [{ type: 'integer' }, NumberSchemaNode],
96
- [{ type: 'bigint' }, NumberSchemaNode],
97
- [{ type: string }, ScalarSchemaNode],
98
- // Inferred scalar types from constraints when no explicit type is present.
99
- [{ minLength: number }, StringSchemaNode],
100
- [{ maxLength: number }, StringSchemaNode],
101
- [{ pattern: string }, StringSchemaNode],
102
- [{ minimum: number }, NumberSchemaNode],
103
- [{ maximum: number }, NumberSchemaNode],
104
- ]
105
-
106
- export type InferSchemaNode<
107
- TSchema extends SchemaObject,
108
- TDateType extends Options['dateType'] = Options['dateType'],
109
- TEntries extends ReadonlyArray<[object, SchemaNode]> = SchemaNodeMap<TDateType>,
110
- > = TEntries extends [infer TEntry extends [object, SchemaNode], ...infer TRest extends ReadonlyArray<[object, SchemaNode]>]
111
- ? TSchema extends TEntry[0]
112
- ? TEntry[1]
113
- : InferSchemaNode<TSchema, TDateType, TRest>
114
- : SchemaNode
115
-
116
- /**
117
- * Controls how various OAS constructs are mapped to Kubb AST nodes.
118
- */
119
- export type Options = {
120
- /**
121
- * How `format: 'date-time'` schemas are represented. `false` falls through to a plain string.
122
- */
123
- dateType: false | 'string' | 'stringOffset' | 'stringLocal' | 'date'
124
- /**
125
- * Whether `type: 'integer'` and `format: 'int64'` produce `number` or `bigint` nodes.
126
- */
127
- integerType?: 'number' | 'bigint'
128
- /**
129
- * AST type used when no schema type can be inferred.
130
- */
131
- unknownType: 'any' | 'unknown' | 'void'
132
- /**
133
- * AST type used for completely empty schemas (`{}`).
134
- */
135
- emptySchemaType: 'any' | 'unknown' | 'void'
136
- /**
137
- * Suffix appended to derived enum names when building property schema names.
138
- */
139
- enumSuffix: string
140
- }
141
-
142
- /**
143
- * Construction-time options for `createOasParser`.
144
- */
145
- export type OasParserOptions = {
146
- contentType?: contentType
147
- collisionDetection?: boolean
148
- }
149
-
150
- /**
151
- * Default values for all `Options` fields.
152
- */
153
- const DEFAULT_OPTIONS = {
154
- dateType: 'string',
155
- integerType: 'number',
156
- unknownType: 'any',
157
- emptySchemaType: 'any',
158
- enumSuffix: 'enum',
159
- } as const satisfies Options
160
-
161
- /**
162
- * Looks up the Kubb `SchemaType` for a given OAS `format` string.
163
- * Returns `undefined` for formats not in `formatMap` (e.g. `int64`, `date-time`),
164
- * which are handled separately because their output depends on parser options.
165
- */
166
- function formatToSchemaType(format: string): SchemaType | undefined {
167
- return formatMap[format as keyof typeof formatMap]
168
- }
169
-
170
- /**
171
- * Maps an OAS primitive type string to its `PrimitiveSchemaType` equivalent.
172
- * Numeric types (`number`, `integer`, `bigint`) are returned unchanged;
173
- * `boolean` maps to `'boolean'`; everything else defaults to `'string'`.
174
- */
175
- function getPrimitiveType(type: string | undefined): PrimitiveSchemaType {
176
- if (type === 'number' || type === 'integer' || type === 'bigint') return type
177
- if (type === 'boolean') return 'boolean'
178
- return 'string'
179
- }
180
-
181
- /**
182
- * Narrows a raw content-type string to the `MediaType` union recognized by Kubb.
183
- * Returns `undefined` for content types not present in `KNOWN_MEDIA_TYPES`.
23
+ * Construction-time context for the OAS parser.
24
+ *
25
+ * Holds the raw OpenAPI document and optional content-type override used when extracting
26
+ * request/response schemas.
184
27
  */
185
- function toMediaType(contentType: string): MediaType | undefined {
186
- return knownMediaTypes.includes(contentType as MediaType) ? (contentType as MediaType) : undefined
28
+ export type OasParserContext = {
29
+ document: Document
30
+ contentType?: ContentType
187
31
  }
188
32
 
189
33
  /**
@@ -192,178 +36,87 @@ function toMediaType(contentType: string): MediaType | undefined {
192
36
  */
193
37
  type SchemaContext = {
194
38
  schema: SchemaObject
195
- name: string | undefined
39
+ name: string | null | undefined
196
40
  nullable: true | undefined
197
41
  defaultValue: unknown
198
42
  /**
199
43
  * Normalized single type string (first element when OAS 3.1 multi-type array).
200
44
  */
201
45
  type: string | undefined
202
- options: Partial<Options> | undefined
203
- mergedOptions: Options
46
+ rawOptions: Partial<ast.ParserOptions> | undefined
47
+ options: ast.ParserOptions
204
48
  }
205
49
 
206
50
  /**
207
- * The public interface returned by `createOasParser`.
51
+ * Normalize a malformed `{ type: 'array', enum: [...] }` schema by moving the
52
+ * enum values into the items sub-schema. This pattern is technically invalid OAS
53
+ * but appears in the wild and must be handled gracefully.
208
54
  */
209
- export type OasParser = {
210
- /**
211
- * Converts an OpenAPI/Swagger spec (wrapped in a Kubb `Oas` instance) into
212
- * a `RootNode` the top-level node of the `@kubb/ast` tree.
213
- */
214
- parse: <TOptions extends Partial<Options> = object>(options?: TOptions) => RootNode
215
- convertSchema: <TFormat extends string, TSchema extends SchemaObject & { format?: TFormat }, TOptions extends Partial<Options> = object>(
216
- params: { schema: TSchema; name?: string },
217
- options?: TOptions,
218
- ) => InferSchemaNode<TSchema, TOptions extends { dateType: Options['dateType'] } ? TOptions['dateType'] : (typeof DEFAULT_OPTIONS)['dateType']>
219
- /**
220
- * Walks `node` and replaces each `ref` value with the name returned by
221
- * `resolveName`. The callback receives the full `$ref` path (e.g. `#/components/schemas/Order`)
222
- * when available, falling back to the short name. Pass a no-op (`(n) => n`) to skip resolution.
223
- *
224
- * The optional `resolveEnumName` callback is called for inline `enum` nodes and should return
225
- * the transformed name to use (e.g. with a plugin `transformers.name` applied).
226
- */
227
- resolveRefs: (node: SchemaNode, resolveName: (ref: string) => string | undefined, resolveEnumName?: (name: string) => string | undefined) => SchemaNode
55
+ function normalizeArrayEnum(schema: SchemaObject): SchemaObject {
56
+ const isItemsObject = typeof schema.items === 'object' && !Array.isArray(schema.items)
57
+ const normalizedItems: SchemaObject = {
58
+ ...(isItemsObject ? (schema.items as SchemaObject) : {}),
59
+ enum: schema.enum,
60
+ }
61
+ const { enum: _enum, ...schemaWithoutEnum } = schema
228
62
 
229
- /**
230
- * Map from original `$ref` paths to their collision-resolved schema names.
231
- * e.g. `'#/components/schemas/Order'` → `'OrderSchema'`
232
- *
233
- * Pass this to the standalone `getImports()` to resolve imports without holding
234
- * a reference to the full parser or OAS instance.
235
- */
236
- nameMapping: Map<string, string>
63
+ return { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject
237
64
  }
238
65
 
239
66
  /**
240
- * Creates an OAS parser that converts an OpenAPI/Swagger spec into
241
- * the `@kubb/ast` tree.
242
- *
243
- * Options are passed per-call to `parse` or `convertSchema` rather than
244
- * at construction time, keeping the factory lightweight.
67
+ * Builds the internal converter functions for a given `OasParserContext`.
245
68
  *
246
- * This is the **kubb-parser** stage of the compilation lifecycle:
247
- * OpenAPI / Swagger → Kubb AST
248
- *
249
- * No code is generated here; the resulting tree is spec-agnostic and can
250
- * be consumed by any downstream plugin (plugin-ts, plugin-zod, …).
251
- *
252
- * @example
253
- * ```ts
254
- * const parser = createOasParser(oas)
255
- * const root = parser.parse({ emptySchemaType: 'unknown' })
256
- * ```
69
+ * All `convert*` functions are defined as function declarations so they can freely
70
+ * reference each other and `parseSchema` via JS hoisting (mutual recursion).
257
71
  */
258
- export function createOasParser(oas: Oas, { contentType, collisionDetection }: OasParserOptions = {}): OasParser {
259
- // Map from original component paths to resolved schema names (after collision resolution)
260
- // e.g., { '#/components/schemas/Order': 'OrderSchema', '#/components/responses/Product': 'ProductResponse' }
261
- const { schemas: schemaObjects, nameMapping } = oas.getSchemas({ contentType, collisionDetection })
72
+ function createSchemaParser(ctx: OasParserContext) {
73
+ const document = ctx.document
74
+
75
+ // Branch handlers each converts one OAS schema pattern to a SchemaNode.
262
76
 
263
77
  /**
264
- * Maps an `'any' | 'unknown' | 'void'` option string to the corresponding `SchemaType` constant.
265
- * Used for both `unknownType` (unannotated schemas) and `emptySchemaType` (empty `{}` schemas).
78
+ * Tracks `$ref` paths that are currently being resolved to prevent infinite
79
+ * recursion when schemas contain circular references (e.g. `Pet parent → Pet`).
266
80
  */
267
- function resolveTypeOption(value: 'any' | 'unknown' | 'void'): ScalarSchemaType {
268
- if (value === 'any') return schemaTypes.any
269
- if (value === 'void') return schemaTypes.void
270
- return schemaTypes.unknown
271
- }
81
+ const resolvingRefs = new Set<string>()
272
82
 
273
83
  /**
274
- * Resolves the AST type and datetime modifiers for a date/time format, honoring the `dateType` option.
275
- * Returns `undefined` when `dateType` is `false`, meaning the format should fall through to `string`.
84
+ * Converts a `$ref` schema into a `RefSchemaNode`.
85
+ *
86
+ * The resolved schema is stored in `node.schema`. Usage-site sibling fields
87
+ * (description, readOnly, nullable, etc.) are stored directly on the ref node.
88
+ * Use `syncSchemaRef(node)` in printers to get a merged view of both.
89
+ * Circular refs are detected via `resolvingRefs` and leave `schema` as `undefined`.
276
90
  */
277
- function getDateType(
278
- options: Options,
279
- format: 'date-time' | 'date' | 'time',
280
- ): { type: 'datetime'; offset?: boolean; local?: boolean } | { type: 'date' | 'time'; representation: 'date' | 'string' } | undefined {
281
- if (!options.dateType) {
282
- return undefined
283
- }
284
-
285
- if (format === 'date-time') {
286
- if (options.dateType === 'date') {
287
- return { type: 'date', representation: 'date' }
288
- }
289
- if (options.dateType === 'stringOffset') {
290
- return { type: 'datetime', offset: true }
291
- }
292
- if (options.dateType === 'stringLocal') {
293
- return { type: 'datetime', local: true }
91
+ function convertRef({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
92
+ let resolvedSchema: ast.SchemaNode | undefined
93
+ const refPath = schema.$ref
94
+ if (refPath && !resolvingRefs.has(refPath)) {
95
+ try {
96
+ const referenced = resolveRef<SchemaObject>(document, refPath)
97
+ if (referenced) {
98
+ resolvingRefs.add(refPath)
99
+ resolvedSchema = parseSchema({ schema: referenced }, rawOptions)
100
+ resolvingRefs.delete(refPath)
101
+ }
102
+ } catch {
103
+ // Ref cannot be resolved in this document (e.g. unit tests with minimal documents).
294
104
  }
295
- return { type: 'datetime', offset: false }
296
105
  }
297
106
 
298
- if (format === 'date') {
299
- return { type: 'date', representation: options.dateType === 'date' ? 'date' : 'string' }
300
- }
301
-
302
- // time
303
- return { type: 'time', representation: options.dateType === 'date' ? 'date' : 'string' }
304
- }
305
-
306
- /**
307
- * Shared metadata fields included in every `createSchema` call.
308
- * Centralizes the common properties so sub-handlers don't repeat them.
309
- */
310
- function buildSchemaBase(schema: SchemaObject, name: string | undefined, nullable: true | undefined, defaultValue: unknown) {
311
- return {
312
- name,
313
- nullable,
314
- title: schema.title,
315
- description: schema.description,
316
- deprecated: schema.deprecated,
317
- readOnly: schema.readOnly,
318
- writeOnly: schema.writeOnly,
319
- default: defaultValue,
320
- example: schema.example,
321
- } as const
322
- }
323
-
324
- // Branch handlers — each converts one OAS schema pattern to a SchemaNode.
325
- // They are defined as function declarations so they can reference each other
326
- // and `convertSchema` freely (JS hoisting).
327
-
328
- /**
329
- * Converts a `$ref` schema pointer into a `RefSchemaNode`.
330
- *
331
- * In OAS 3.0 siblings of `$ref` are technically ignored by the spec, but Kubb intentionally
332
- * preserves them so that annotations like `pattern`, `description`, and `nullable` are
333
- * reflected in generated JSDoc and type modifiers.
334
- */
335
- function convertRef({ schema, nullable, defaultValue }: SchemaContext): SchemaNode {
336
- return createSchema({
107
+ return ast.createSchema({
108
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
337
109
  type: 'ref',
338
- name: extractRefName(schema.$ref!),
110
+ name: ast.extractRefName(schema.$ref!),
339
111
  ref: schema.$ref,
340
- nullable,
341
- description: schema.description,
342
- deprecated: schema.deprecated,
343
- readOnly: schema.readOnly,
344
- writeOnly: schema.writeOnly,
345
- pattern: schema.type === 'string' ? schema.pattern : undefined,
346
- example: schema.example,
347
- default: defaultValue,
112
+ schema: resolvedSchema,
348
113
  })
349
114
  }
350
115
 
351
116
  /**
352
- * Converts a `allOf` schema into either a flattened member node (single-member `allOf`)
353
- * or an `IntersectionSchemaNode` (multi-member `allOf`).
354
- *
355
- * Single-member `allOf` without sibling structural keys is the common OAS 3.0 pattern for
356
- * annotating a `$ref` or primitive with extra constraints; it is flattened to avoid
357
- * producing needless intersection wrappers.
358
- *
359
- * The flatten path is skipped when the outer schema carries structural keys that cannot be
360
- * merged into annotation fields: `properties`, `required`, or `additionalProperties`.
361
- * Those cases must become an intersection so the constraints are preserved.
362
- *
363
- * Circular references through discriminator parents are detected and skipped to prevent
364
- * infinite recursion during code generation.
117
+ * Converts an `allOf` schema into a flattened node or an `IntersectionSchemaNode`.
365
118
  */
366
- function convertAllOf({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
119
+ function convertAllOf({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
367
120
  if (
368
121
  schema.allOf!.length === 1 &&
369
122
  !schema.properties &&
@@ -371,12 +124,12 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
371
124
  schema.additionalProperties === undefined
372
125
  ) {
373
126
  const [memberSchema] = schema.allOf as Array<SchemaObject | ReferenceObject>
374
- const memberNode = convertSchema({ schema: memberSchema! as SchemaObject }, options)
127
+ const memberNode = parseSchema({ schema: memberSchema! as SchemaObject, name: null }, rawOptions)
375
128
  const { kind: _kind, ...memberNodeProps } = memberNode
376
129
  const mergedNullable = nullable || memberNode.nullable || undefined
377
130
  const mergedDefault = schema.default === null && mergedNullable ? undefined : (schema.default ?? memberNode.default)
378
131
 
379
- return createSchema({
132
+ return ast.createSchema({
380
133
  ...memberNodeProps,
381
134
  name,
382
135
  title: schema.title ?? memberNode.title,
@@ -388,31 +141,39 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
388
141
  default: mergedDefault,
389
142
  example: schema.example ?? memberNode.example,
390
143
  pattern: schema.pattern ?? ('pattern' in memberNode ? memberNode.pattern : undefined),
391
- } as DistributiveOmit<SchemaNode, 'kind'>)
144
+ } as ast.DistributiveOmit<ast.SchemaNode, 'kind'>)
392
145
  }
393
146
 
394
- // When a child schema extends a discriminator parent via allOf and the parent's oneOf/anyOf
395
- // references that child back, skip that allOf item to prevent a circular type reference.
396
- const allOfMembers: Array<SchemaNode> = (schema.allOf as Array<SchemaObject | ReferenceObject>)
147
+ const filteredDiscriminantValues: Array<{
148
+ propertyName: string
149
+ value: string
150
+ }> = []
151
+ const allOfMembers: Array<ast.SchemaNode> = (schema.allOf as Array<SchemaObject | ReferenceObject>)
397
152
  .filter((item) => {
398
153
  if (!isReference(item) || !name) return true
399
- const deref = oas.get<SchemaObject>(item.$ref)
154
+ const deref = resolveRef<SchemaObject>(document, item.$ref)
400
155
  if (!deref || !isDiscriminator(deref)) return true
401
156
  const parentUnion = deref.oneOf ?? deref.anyOf
402
157
  if (!parentUnion) return true
403
- const childRef = `#/components/schemas/${name}`
158
+ const childRef = `${SCHEMA_REF_PREFIX}${name}`
404
159
  const inOneOf = parentUnion.some((oneOfItem) => isReference(oneOfItem) && oneOfItem.$ref === childRef)
405
160
  const inMapping = Object.values(deref.discriminator.mapping ?? {}).some((v) => v === childRef)
406
- return !inOneOf && !inMapping
161
+ if (inOneOf || inMapping) {
162
+ const discriminatorValue = ast.findDiscriminator(deref.discriminator.mapping, childRef)
163
+ if (discriminatorValue) {
164
+ filteredDiscriminantValues.push({
165
+ propertyName: deref.discriminator.propertyName,
166
+ value: discriminatorValue,
167
+ })
168
+ }
169
+ return false
170
+ }
171
+ return true
407
172
  })
408
- .map((s) => convertSchema({ schema: s as SchemaObject }, options))
173
+ .map((s) => parseSchema({ schema: s as SchemaObject }, rawOptions))
409
174
 
410
- // Track where allOf-derived members end so only the synthetic members added below
411
- // (injected required-key objects + outer-properties object) are candidates for merging.
412
175
  const syntheticStart = allOfMembers.length
413
176
 
414
- // When `required` lists keys not present in the outer `properties`, resolve them from
415
- // the allOf member schemas and inject them as extra intersection members.
416
177
  if (Array.isArray(schema.required) && schema.required.length) {
417
178
  const outerKeys = schema.properties ? new Set(Object.keys(schema.properties)) : new Set<string>()
418
179
  const missingRequired = schema.required.filter((key) => !outerKeys.has(key))
@@ -420,14 +181,24 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
420
181
  if (missingRequired.length) {
421
182
  const resolvedMembers = (schema.allOf as Array<SchemaObject | ReferenceObject>).flatMap((item) => {
422
183
  if (!isReference(item)) return [item as SchemaObject]
423
- const deref = oas.get<SchemaObject>(item.$ref)
184
+ const deref = resolveRef<SchemaObject>(document, item.$ref)
424
185
  return deref && !isReference(deref) ? [deref] : []
425
186
  })
426
187
 
427
188
  for (const key of missingRequired) {
428
189
  for (const resolved of resolvedMembers) {
429
190
  if (resolved.properties?.[key]) {
430
- allOfMembers.push(convertSchema({ schema: { properties: { [key]: resolved.properties[key] }, required: [key] } as SchemaObject }, options))
191
+ allOfMembers.push(
192
+ parseSchema(
193
+ {
194
+ schema: {
195
+ properties: { [key]: resolved.properties[key] },
196
+ required: [key],
197
+ } as SchemaObject,
198
+ },
199
+ rawOptions,
200
+ ),
201
+ )
431
202
  break
432
203
  }
433
204
  }
@@ -437,110 +208,151 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
437
208
 
438
209
  if (schema.properties) {
439
210
  const { allOf: _allOf, ...schemaWithoutAllOf } = schema
440
- allOfMembers.push(convertSchema({ schema: schemaWithoutAllOf }, options))
211
+ allOfMembers.push(parseSchema({ schema: schemaWithoutAllOf }, rawOptions))
212
+ }
213
+
214
+ for (const { propertyName, value } of filteredDiscriminantValues) {
215
+ allOfMembers.push(ast.createDiscriminantNode({ propertyName, value }))
441
216
  }
442
217
 
443
- // Merge consecutive anonymous object members within the synthetic portion — see `mergeAdjacentAnonymousObjects`.
444
- return createSchema({
218
+ return ast.createSchema({
445
219
  type: 'intersection',
446
- members: [...allOfMembers.slice(0, syntheticStart), ...mergeAdjacentAnonymousObjects(allOfMembers.slice(syntheticStart))],
447
- ...buildSchemaBase(schema, name, nullable, defaultValue),
220
+ members: [...ast.mergeAdjacentObjects(allOfMembers.slice(0, syntheticStart)), ...ast.mergeAdjacentObjects(allOfMembers.slice(syntheticStart))],
221
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
448
222
  })
449
223
  }
450
224
 
451
225
  /**
452
226
  * Converts a `oneOf` / `anyOf` schema into a `UnionSchemaNode`.
453
- *
454
- * Both keywords are treated identically — their members are concatenated into a single union.
455
- * When sibling `properties` are present alongside `oneOf`/`anyOf`, each union member is
456
- * individually intersected with the shared properties node to match the OAS pattern of
457
- * adding common fields next to a discriminated union.
458
227
  */
459
- function convertUnion({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
228
+ function convertUnion({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
229
+ function pickDiscriminatorPropertyNode(node: ast.SchemaNode, propertyName: string): ast.SchemaNode | null {
230
+ const objectNode = ast.narrowSchema(node, 'object')
231
+ const discriminatorProperty = objectNode?.properties?.find((property) => property.name === propertyName)
232
+
233
+ if (!discriminatorProperty) {
234
+ return null
235
+ }
236
+
237
+ return ast.createSchema({
238
+ type: 'object',
239
+ primitive: 'object',
240
+ properties: [discriminatorProperty],
241
+ })
242
+ }
243
+
460
244
  const unionMembers = [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]
245
+ const strategy: 'one' | 'any' = schema.oneOf ? 'one' : 'any'
461
246
  const unionBase = {
462
- ...buildSchemaBase(schema, name, nullable, defaultValue),
247
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
463
248
  discriminatorPropertyName: isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
249
+ strategy,
464
250
  }
251
+ const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
252
+ const sharedPropertiesNode = schema.properties
253
+ ? (() => {
254
+ const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
255
+ const memberBaseSchema: SchemaObject = discriminator
256
+ ? (Object.fromEntries(Object.entries(schemaWithoutUnion).filter(([key]) => key !== 'discriminator')) as SchemaObject)
257
+ : schemaWithoutUnion
258
+ return parseSchema({ schema: memberBaseSchema, name }, rawOptions)
259
+ })()
260
+ : undefined
465
261
 
466
- if (schema.properties) {
467
- const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
468
- const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
262
+ if (sharedPropertiesNode || discriminator?.mapping) {
263
+ const members = unionMembers.map((s) => {
264
+ const ref = isReference(s) ? s.$ref : undefined
265
+ const discriminatorValue = ast.findDiscriminator(discriminator?.mapping, ref)
266
+ const memberNode = parseSchema({ schema: s as SchemaObject }, rawOptions)
469
267
 
470
- // Strip discriminator so convertObject won't re-apply the full mapping enum.
471
- const memberBaseSchema: SchemaObject = discriminator
472
- ? (Object.fromEntries(Object.entries(schemaWithoutUnion).filter(([key]) => key !== 'discriminator')) as SchemaObject)
473
- : schemaWithoutUnion
268
+ if (!discriminatorValue || !discriminator) {
269
+ return memberNode
270
+ }
474
271
 
475
- return createSchema({
272
+ const narrowedDiscriminatorNode = sharedPropertiesNode
273
+ ? pickDiscriminatorPropertyNode(
274
+ ast.setDiscriminatorEnum({
275
+ node: sharedPropertiesNode,
276
+ propertyName: discriminator.propertyName,
277
+ values: [discriminatorValue],
278
+ }),
279
+ discriminator.propertyName,
280
+ )
281
+ : undefined
282
+
283
+ return ast.createSchema({
284
+ type: 'intersection',
285
+ members: [
286
+ memberNode,
287
+ narrowedDiscriminatorNode ??
288
+ ast.createDiscriminantNode({
289
+ propertyName: discriminator.propertyName,
290
+ value: discriminatorValue,
291
+ }),
292
+ ],
293
+ })
294
+ })
295
+
296
+ const unionNode = ast.createSchema({
476
297
  type: 'union',
477
298
  ...unionBase,
478
- members: unionMembers.map((s) => {
479
- const ref = isReference(s) ? s.$ref : undefined
480
- const discriminatorValue = discriminator?.mapping && ref ? Object.entries(discriminator.mapping).find(([, v]) => v === ref)?.[0] : undefined
481
-
482
- let propertiesNode = convertSchema({ schema: memberBaseSchema, name }, options)
299
+ members,
300
+ })
483
301
 
484
- if (discriminatorValue && discriminator) {
485
- propertiesNode = applyDiscriminatorEnum({ node: propertiesNode, propertyName: discriminator.propertyName, values: [discriminatorValue] })
486
- }
302
+ if (!sharedPropertiesNode) {
303
+ return unionNode
304
+ }
487
305
 
488
- return createSchema({
489
- type: 'intersection',
490
- members: [convertSchema({ schema: s as SchemaObject }, options), propertiesNode],
491
- })
492
- }),
306
+ return ast.createSchema({
307
+ type: 'intersection',
308
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
309
+ members: [unionNode, sharedPropertiesNode],
493
310
  })
494
311
  }
495
312
 
496
- return createSchema({
313
+ return ast.createSchema({
497
314
  type: 'union',
498
315
  ...unionBase,
499
- members: simplifyUnionMembers(unionMembers.map((s) => convertSchema({ schema: s as SchemaObject }, options))),
316
+ members: ast.simplifyUnion(unionMembers.map((s) => parseSchema({ schema: s as SchemaObject }, rawOptions))),
500
317
  })
501
318
  }
502
319
 
503
320
  /**
504
- * Converts an OAS 3.1 `const` schema into either a null scalar or a single-value `EnumSchemaNode`.
505
- * `const: null` maps to a null scalar; any other value becomes a one-item enum so that generators
506
- * can produce a precise literal type.
321
+ * Converts an OAS 3.1 `const` schema into a null scalar or a single-value `EnumSchemaNode`.
507
322
  */
508
- function convertConst({ schema, name, nullable, defaultValue }: SchemaContext): SchemaNode {
323
+ function convertConst({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
509
324
  const constValue = schema.const
510
325
 
511
326
  if (constValue === null) {
512
- return createSchema({
327
+ return ast.createSchema({
513
328
  type: 'null',
514
329
  primitive: 'null',
515
330
  name,
516
331
  title: schema.title,
517
332
  description: schema.description,
518
333
  deprecated: schema.deprecated,
519
- nullable,
520
334
  })
521
335
  }
522
336
 
523
337
  const constPrimitive = getPrimitiveType(typeof constValue === 'number' ? 'number' : typeof constValue === 'boolean' ? 'boolean' : 'string')
524
- return createSchema({
338
+ return ast.createSchema({
525
339
  type: 'enum',
526
340
  primitive: constPrimitive,
527
341
  enumValues: [constValue as string | number | boolean],
528
- ...buildSchemaBase(schema, name, nullable, defaultValue),
342
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
529
343
  })
530
344
  }
531
345
 
532
346
  /**
533
- * Handles `format`-based special types (date/time, uuid, email, blob, etc.).
534
- * Returns `undefined` when the format should fall through to string handling
535
- * (i.e. `format: 'date-time'` with `dateType: false`).
347
+ * Converts a format-annotated schema into a special-type `SchemaNode`.
348
+ * Returns `null` when the format should fall through to string handling (`dateType: false`).
536
349
  */
537
- function convertFormat({ schema, name, nullable, defaultValue, mergedOptions }: SchemaContext): SchemaNode | undefined {
538
- const base = buildSchemaBase(schema, name, nullable, defaultValue)
350
+ function convertFormat({ schema, name, nullable, defaultValue, options }: SchemaContext): ast.SchemaNode | null {
351
+ const base = buildSchemaNode(schema, name, nullable, defaultValue)
539
352
 
540
- // int64 is option-dependent so it can't live in the static formatMap.
541
353
  if (schema.format === 'int64') {
542
- return createSchema({
543
- type: mergedOptions.integerType === 'bigint' ? 'bigint' : 'integer',
354
+ return ast.createSchema({
355
+ type: options.integerType === 'bigint' ? 'bigint' : 'integer',
544
356
  primitive: 'integer',
545
357
  ...base,
546
358
  min: schema.minimum,
@@ -550,52 +362,87 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
550
362
  })
551
363
  }
552
364
 
553
- // date-time / date / time are option-dependent and can't live in the static formatMap.
554
365
  if (schema.format === 'date-time' || schema.format === 'date' || schema.format === 'time') {
555
- const dateType = getDateType(mergedOptions, schema.format)
556
- if (!dateType) return undefined // dateType: false → fall through to string
366
+ const dateType = getDateType(options, schema.format)
367
+ if (!dateType) return null
557
368
 
558
369
  if (dateType.type === 'datetime') {
559
- return createSchema({ ...base, primitive: 'string' as const, type: 'datetime', offset: dateType.offset, local: dateType.local })
370
+ return ast.createSchema({
371
+ ...base,
372
+ primitive: 'string' as const,
373
+ type: 'datetime',
374
+ offset: dateType.offset,
375
+ local: dateType.local,
376
+ })
560
377
  }
561
- return createSchema({ ...base, primitive: 'string' as const, type: dateType.type, representation: dateType.representation })
378
+ return ast.createSchema({
379
+ ...base,
380
+ primitive: 'string' as const,
381
+ type: dateType.type,
382
+ representation: dateType.representation,
383
+ })
562
384
  }
563
385
 
564
- const specialType = formatToSchemaType(schema.format!)
565
- if (!specialType) return undefined
386
+ const specialType = getSchemaType(schema.format!)
387
+ if (!specialType) return null
566
388
 
567
- const specialPrimitive: PrimitiveSchemaType = specialType === 'number' || specialType === 'integer' || specialType === 'bigint' ? specialType : 'string'
389
+ const specialPrimitive: ast.PrimitiveSchemaType = specialType === 'number' || specialType === 'integer' || specialType === 'bigint' ? specialType : 'string'
568
390
 
569
391
  if (specialType === 'number' || specialType === 'integer' || specialType === 'bigint') {
570
- return createSchema({ ...base, primitive: specialPrimitive, type: specialType })
392
+ return ast.createSchema({
393
+ ...base,
394
+ primitive: specialPrimitive,
395
+ type: specialType,
396
+ })
571
397
  }
572
398
  if (specialType === 'url') {
573
- return createSchema({ ...base, primitive: 'string' as const, type: 'url' })
399
+ return ast.createSchema({
400
+ ...base,
401
+ primitive: 'string' as const,
402
+ type: 'url',
403
+ min: schema.minLength,
404
+ max: schema.maxLength,
405
+ })
406
+ }
407
+ if (specialType === 'ipv4') {
408
+ return ast.createSchema({
409
+ ...base,
410
+ primitive: 'string' as const,
411
+ type: 'ipv4',
412
+ })
413
+ }
414
+ if (specialType === 'ipv6') {
415
+ return ast.createSchema({
416
+ ...base,
417
+ primitive: 'string' as const,
418
+ type: 'ipv6',
419
+ })
420
+ }
421
+ if (specialType === 'uuid' || specialType === 'email') {
422
+ return ast.createSchema({
423
+ ...base,
424
+ primitive: 'string' as const,
425
+ type: specialType,
426
+ min: schema.minLength,
427
+ max: schema.maxLength,
428
+ })
574
429
  }
575
430
 
576
- return createSchema({ ...base, primitive: specialPrimitive, type: specialType as ScalarSchemaType })
431
+ return ast.createSchema({
432
+ ...base,
433
+ primitive: specialPrimitive,
434
+ type: specialType as ast.ScalarSchemaType,
435
+ })
577
436
  }
578
437
 
579
438
  /**
580
439
  * Converts an `enum` schema into an `EnumSchemaNode`.
581
- *
582
- * Handles several edge cases:
583
- * - `{ type: 'array', enum }` (technically invalid OAS) — the enum is normalized into `items`.
584
- * - `null` in enum values (OAS 3.0 nullable enum convention) — stripped and reflected as `nullable`.
585
- * - `x-enumNames` / `x-enum-varnames` vendor extensions — produce named enum variants with explicit labels.
586
- * - Numeric and boolean enums require a const-map representation because most generators cannot
587
- * use string-enum syntax for non-string values.
588
440
  */
589
- function convertEnum({ schema, name, nullable, type, options }: SchemaContext): SchemaNode {
590
- // Malformed schema: `{ type: 'array', enum: [...] }` — normalize by moving the enum into items.
441
+ function convertEnum({ schema, name, nullable, type, rawOptions }: SchemaContext): ast.SchemaNode {
591
442
  if (type === 'array') {
592
- const isItemsObject = typeof schema.items === 'object' && !Array.isArray(schema.items)
593
- const normalizedItems: SchemaObject = { ...(isItemsObject ? (schema.items as SchemaObject) : {}), enum: schema.enum }
594
- const { enum: _enum, ...schemaWithoutEnum } = schema
595
- return convertSchema({ schema: { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject, name }, options)
443
+ return parseSchema({ schema: normalizeArrayEnum(schema), name }, rawOptions)
596
444
  }
597
445
 
598
- // `null` in enum values is the OAS 3.0 convention for a nullable enum.
599
446
  const nullInEnum = schema.enum!.includes(null)
600
447
  const filteredValues = (nullInEnum ? schema.enum!.filter((v) => v !== null) : schema.enum!) as Array<string | number | boolean>
601
448
  const enumNullable = nullable || nullInEnum || undefined
@@ -616,93 +463,66 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
616
463
  example: schema.example,
617
464
  }
618
465
 
619
- // x-enumNames / x-enum-varnames: named variants with explicit labels take priority.
620
466
  const extensionKey = enumExtensionKeys.find((key) => key in schema)
621
- if (extensionKey) {
622
- const rawNames = (schema as Record<string, unknown>)[extensionKey] as Array<string | number>
623
- const uniqueNames = [...new Set(rawNames)]
624
- const enumType =
625
- getPrimitiveType(type) === 'number' || getPrimitiveType(type) === 'integer'
626
- ? ('number' as const)
627
- : getPrimitiveType(type) === 'boolean'
628
- ? ('boolean' as const)
629
- : ('string' as const)
630
-
631
- return createSchema({
467
+ if (extensionKey || enumPrimitive === 'number' || enumPrimitive === 'integer' || enumPrimitive === 'boolean') {
468
+ const enumPrimitiveType = (enumPrimitive === 'number' || enumPrimitive === 'integer' ? 'number' : enumPrimitive === 'boolean' ? 'boolean' : 'string') as
469
+ | 'number'
470
+ | 'boolean'
471
+ | 'string'
472
+ const rawEnumNames = extensionKey ? ((schema as Record<string, unknown>)[extensionKey] as Array<string | number>) : undefined
473
+ const uniqueValues = [...new Set(filteredValues)]
474
+ const seenNames = new Set<string>()
475
+
476
+ return ast.createSchema({
632
477
  ...enumBase,
633
- enumType,
634
- namedEnumValues: uniqueNames.map((label, index) => ({
635
- name: String(label),
636
- value: filteredValues[index] ?? label,
637
- format: enumType,
638
- })),
478
+ primitive: enumPrimitiveType,
479
+ namedEnumValues: uniqueValues
480
+ .map((value, index) => ({
481
+ name: String(rawEnumNames?.[index] ?? value),
482
+ value,
483
+ primitive: enumPrimitiveType,
484
+ }))
485
+ .filter((entry) => {
486
+ if (seenNames.has(entry.name)) return false
487
+ seenNames.add(entry.name)
488
+ return true
489
+ }),
639
490
  })
640
491
  }
641
492
 
642
- // Number / integer enum — must use a const map since most generators can't use string-enum for numbers.
643
- if (type === 'number' || type === 'integer') {
644
- return createSchema({
645
- ...enumBase,
646
- enumType: 'number' as const,
647
- namedEnumValues: [...new Set(filteredValues)].map((value) => ({
648
- name: String(value),
649
- value: value as number,
650
- format: 'number' as const,
651
- })),
652
- })
653
- }
654
-
655
- // Boolean enum — same const-map approach as numeric.
656
- if (type === 'boolean') {
657
- return createSchema({
658
- ...enumBase,
659
- enumType: 'boolean' as const,
660
- namedEnumValues: [...new Set(filteredValues)].map((value) => ({
661
- name: String(value),
662
- value: value as boolean,
663
- format: 'boolean' as const,
664
- })),
665
- })
666
- }
667
-
668
- // Plain string enum (default path).
669
- return createSchema({
493
+ return ast.createSchema({
670
494
  ...enumBase,
671
495
  enumValues: [...new Set(filteredValues)],
672
496
  })
673
497
  }
674
498
 
675
499
  /**
676
- * Converts an object-like schema (`type: 'object'`, `properties`, `additionalProperties`,
677
- * or `patternProperties`) into an `ObjectSchemaNode`.
678
- *
679
- * When a `discriminator` is present, the discriminator property's schema is replaced with an
680
- * enum of the mapping keys so generators can produce a precise literal-union type for it.
681
- *
682
- * Property optionality follows OAS semantics:
683
- * - required + not nullable → `required: true`
684
- * - not required + not nullable → `optional: true`
685
- * - not required + nullable → `nullish: true`
500
+ * Converts an object-like schema into an `ObjectSchemaNode`.
686
501
  */
687
- function convertObject({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
688
- const properties: Array<PropertyNode> = schema.properties
502
+ function convertObject({ schema, name, nullable, defaultValue, rawOptions, options }: SchemaContext): ast.SchemaNode {
503
+ const properties: Array<ast.PropertyNode> = schema.properties
689
504
  ? Object.entries(schema.properties).map(([propName, propSchema]) => {
690
505
  const required = Array.isArray(schema.required) ? schema.required.includes(propName) : !!schema.required
691
506
  const resolvedPropSchema = propSchema as SchemaObject
692
507
  const propNullable = isNullable(resolvedPropSchema)
693
- const basePropName = name ? pascalCase([name, propName].join(' ')) : undefined
694
- const propNode = convertSchema({ schema: resolvedPropSchema, name: basePropName }, options)
695
- const isEnumNode = !!narrowSchema(propNode, 'enum')
696
- const derivedPropName = isEnumNode && name ? pascalCase([name, propName, mergedOptions.enumSuffix].filter(Boolean).join(' ')) : basePropName
697
- const schemaNode = isEnumNode && derivedPropName !== basePropName ? { ...propNode, name: derivedPropName } : propNode
698
508
 
699
- return createProperty({
509
+ const resolvedChildName = ast.childName(name, propName)
510
+ const propNode = parseSchema({ schema: resolvedPropSchema, name: resolvedChildName }, rawOptions)
511
+ let schemaNode = ast.setEnumName(propNode, name, propName, options.enumSuffix)
512
+
513
+ const tupleNode = ast.narrowSchema(schemaNode, 'tuple')
514
+ if (tupleNode?.items) {
515
+ const namedItems = tupleNode.items.map((item) => ast.setEnumName(item, name, propName, options.enumSuffix))
516
+ if (namedItems.some((item, i) => item !== tupleNode.items![i])) {
517
+ schemaNode = { ...tupleNode, items: namedItems }
518
+ }
519
+ }
520
+
521
+ return ast.createProperty({
700
522
  name: propName,
701
523
  schema: {
702
524
  ...schemaNode,
703
- nullable: propNullable || undefined,
704
- optional: !required && !propNullable ? true : undefined,
705
- nullish: !required && propNullable ? true : undefined,
525
+ nullable: schemaNode.type === 'null' ? undefined : propNullable || undefined,
706
526
  },
707
527
  required,
708
528
  })
@@ -710,15 +530,17 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
710
530
  : []
711
531
 
712
532
  const additionalProperties = schema.additionalProperties
713
- let additionalPropertiesNode: SchemaNode | true | undefined
533
+ let additionalPropertiesNode: ast.SchemaNode | boolean | undefined
714
534
  if (additionalProperties === true) {
715
535
  additionalPropertiesNode = true
716
536
  } else if (additionalProperties && Object.keys(additionalProperties).length > 0) {
717
- additionalPropertiesNode = convertSchema({ schema: additionalProperties as SchemaObject }, options)
537
+ additionalPropertiesNode = parseSchema({ schema: additionalProperties as SchemaObject }, rawOptions)
718
538
  } else if (additionalProperties === false) {
719
- additionalPropertiesNode = undefined
539
+ additionalPropertiesNode = false
720
540
  } else if (additionalProperties) {
721
- additionalPropertiesNode = createSchema({ type: resolveTypeOption(mergedOptions.unknownType) })
541
+ additionalPropertiesNode = ast.createSchema({
542
+ type: typeOptionMap.get(options.unknownType)!,
543
+ })
722
544
  }
723
545
 
724
546
  const rawPatternProperties = 'patternProperties' in schema ? schema.patternProperties : undefined
@@ -728,28 +550,35 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
728
550
  Object.entries(rawPatternProperties).map(([pattern, patternSchema]) => [
729
551
  pattern,
730
552
  patternSchema === true || (typeof patternSchema === 'object' && Object.keys(patternSchema).length === 0)
731
- ? createSchema({ type: resolveTypeOption(mergedOptions.unknownType) })
732
- : convertSchema({ schema: patternSchema as SchemaObject }, options),
553
+ ? ast.createSchema({
554
+ type: typeOptionMap.get(options.unknownType)!,
555
+ })
556
+ : parseSchema({ schema: patternSchema as SchemaObject }, rawOptions),
733
557
  ]),
734
558
  )
735
559
  : undefined
736
560
 
737
- const objectNode: SchemaNode = createSchema({
561
+ const objectNode: ast.SchemaNode = ast.createSchema({
738
562
  type: 'object',
739
563
  primitive: 'object',
740
564
  properties,
741
565
  additionalProperties: additionalPropertiesNode,
742
566
  patternProperties,
743
- ...buildSchemaBase(schema, name, nullable, defaultValue),
567
+ minProperties: schema.minProperties,
568
+ maxProperties: schema.maxProperties,
569
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
744
570
  })
745
571
 
746
- // When a discriminator is present, replace the discriminator property's schema
747
- // with an enum of the mapping keys for a precise literal-union type.
748
572
  if (isDiscriminator(schema) && schema.discriminator.mapping) {
749
573
  const discPropName = schema.discriminator.propertyName
750
574
  const values = Object.keys(schema.discriminator.mapping)
751
- const enumName = name ? pascalCase([name, discPropName, mergedOptions.enumSuffix].filter(Boolean).join(' ')) : undefined
752
- return applyDiscriminatorEnum({ node: objectNode, propertyName: discPropName, values, enumName })
575
+ const enumName = name ? ast.enumPropName(name, discPropName, options.enumSuffix) : undefined
576
+ return ast.setDiscriminatorEnum({
577
+ node: objectNode,
578
+ propertyName: discPropName,
579
+ values,
580
+ enumName,
581
+ })
753
582
  }
754
583
 
755
584
  return objectNode
@@ -757,94 +586,87 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
757
586
 
758
587
  /**
759
588
  * Converts an OAS 3.1 `prefixItems` tuple into a `TupleSchemaNode`.
760
- *
761
- * Each `prefixItems` element maps to a positional tuple slot. An optional `items` schema
762
- * after the prefix items is mapped to the rest parameter of the tuple.
763
589
  */
764
- function convertTuple({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
765
- const tupleItems = (schema.prefixItems ?? []).map((item) => convertSchema({ schema: item as SchemaObject }, options))
766
- const rest = schema.items ? convertSchema({ schema: schema.items as SchemaObject }, options) : undefined
590
+ function convertTuple({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
591
+ const tupleItems = (schema.prefixItems ?? []).map((item) => parseSchema({ schema: item as SchemaObject }, rawOptions))
592
+ const rest = schema.items ? parseSchema({ schema: schema.items as SchemaObject }, rawOptions) : ast.createSchema({ type: 'any' })
767
593
 
768
- return createSchema({
594
+ return ast.createSchema({
769
595
  type: 'tuple',
770
596
  primitive: 'array',
771
597
  items: tupleItems,
772
598
  rest,
773
599
  min: schema.minItems,
774
600
  max: schema.maxItems,
775
- ...buildSchemaBase(schema, name, nullable, defaultValue),
601
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
776
602
  })
777
603
  }
778
604
 
779
605
  /**
780
606
  * Converts a `type: 'array'` schema into an `ArraySchemaNode`.
781
- *
782
- * When the items schema is an inline enum, a name derived from the parent array's name and
783
- * `enumSuffix` is forwarded so generators can emit a named enum declaration.
784
607
  */
785
- function convertArray({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
608
+ function convertArray({ schema, name, nullable, defaultValue, rawOptions, options }: SchemaContext): ast.SchemaNode {
786
609
  const rawItems = schema.items as SchemaObject | undefined
787
- // When the array items schema contains an inline enum, derive a name from the parent
788
- // array's name + enumSuffix so generators can emit a named enum declaration.
789
- const itemName = rawItems?.enum?.length && name ? pascalCase([name, mergedOptions.enumSuffix].join(' ')) : undefined
790
- const items = rawItems ? [convertSchema({ schema: rawItems, name: itemName }, options)] : []
610
+ const itemName = rawItems?.enum?.length && name ? ast.enumPropName(undefined, name, options.enumSuffix) : undefined
611
+ const items = rawItems ? [parseSchema({ schema: rawItems, name: itemName }, rawOptions)] : []
791
612
 
792
- return createSchema({
613
+ return ast.createSchema({
793
614
  type: 'array',
794
615
  primitive: 'array',
795
616
  items,
796
617
  min: schema.minItems,
797
618
  max: schema.maxItems,
798
619
  unique: schema.uniqueItems ?? undefined,
799
- ...buildSchemaBase(schema, name, nullable, defaultValue),
620
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
800
621
  })
801
622
  }
802
623
 
803
624
  /**
804
- * Converts a `type: 'string'` schema (without a special format) into a `StringSchemaNode`.
625
+ * Converts a `type: 'string'` schema into a `StringSchemaNode`.
805
626
  */
806
- function convertString({ schema, name, nullable, defaultValue }: SchemaContext): SchemaNode {
807
- return createSchema({
627
+ function convertString({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
628
+ return ast.createSchema({
808
629
  type: 'string',
809
630
  primitive: 'string',
810
631
  min: schema.minLength,
811
632
  max: schema.maxLength,
812
633
  pattern: schema.pattern,
813
- ...buildSchemaBase(schema, name, nullable, defaultValue),
634
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
814
635
  })
815
636
  }
816
637
 
817
638
  /**
818
- * Converts a `type: 'number'` or `type: 'integer'` schema into the corresponding `SchemaNode`.
639
+ * Converts a `type: 'number'` or `type: 'integer'` schema.
819
640
  */
820
- function convertNumeric({ schema, name, nullable, defaultValue }: SchemaContext, type: 'number' | 'integer'): SchemaNode {
821
- return createSchema({
641
+ function convertNumeric({ schema, name, nullable, defaultValue }: SchemaContext, type: 'number' | 'integer'): ast.SchemaNode {
642
+ return ast.createSchema({
822
643
  type,
823
644
  primitive: type,
824
645
  min: schema.minimum,
825
646
  max: schema.maximum,
826
647
  exclusiveMinimum: typeof schema.exclusiveMinimum === 'number' ? schema.exclusiveMinimum : undefined,
827
648
  exclusiveMaximum: typeof schema.exclusiveMaximum === 'number' ? schema.exclusiveMaximum : undefined,
828
- ...buildSchemaBase(schema, name, nullable, defaultValue),
649
+ multipleOf: schema.multipleOf,
650
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
829
651
  })
830
652
  }
831
653
 
832
654
  /**
833
- * Converts a `type: 'boolean'` schema into a `BooleanSchemaNode`.
655
+ * Converts a `type: 'boolean'` schema.
834
656
  */
835
- function convertBoolean({ schema, name, nullable, defaultValue }: SchemaContext): SchemaNode {
836
- return createSchema({
657
+ function convertBoolean({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
658
+ return ast.createSchema({
837
659
  type: 'boolean',
838
660
  primitive: 'boolean',
839
- ...buildSchemaBase(schema, name, nullable, defaultValue),
661
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
840
662
  })
841
663
  }
842
664
 
843
665
  /**
844
- * Converts an explicit `type: 'null'` or `const: null` schema into a `NullSchemaNode`.
666
+ * Converts an explicit `type: 'null'` schema.
845
667
  */
846
- function convertNull({ schema, name, nullable }: SchemaContext): SchemaNode {
847
- return createSchema({
668
+ function convertNull({ schema, name, nullable }: SchemaContext): ast.SchemaNode {
669
+ return ast.createSchema({
848
670
  type: 'null',
849
671
  primitive: 'null',
850
672
  name,
@@ -856,83 +678,70 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
856
678
  }
857
679
 
858
680
  /**
859
- * Central dispatcher: converts an OAS `SchemaObject` into a `SchemaNode`.
681
+ * Central dispatcher that converts an OAS `SchemaObject` into a `SchemaNode`.
860
682
  *
861
- * Dispatch order (first match wins):
862
- * 1. `$ref` pointer
863
- * 2. `allOf` composition
864
- * 3. `oneOf` / `anyOf` union
865
- * 4. `const` literal (OAS 3.1)
866
- * 5. `format`-based special type (date/time, uuid, blob, …)
867
- * 6. OAS 3.1 `contentMediaType: 'application/octet-stream'` blob
868
- * 7. OAS 3.1 multi-type array → union or fallthrough
869
- * 8. Constraint-inferred type (minLength/maxLength → string; minimum/maximum → number)
870
- * 9. `enum` values
871
- * 10. Object / array / tuple / scalar by `type`
872
- * 11. Empty schema fallback (`emptySchemaType` option)
683
+ * Dispatch order (first match wins): `$ref` → `allOf` → `oneOf`/`anyOf` → `const` → `format`
684
+ * octet-stream blob → multi-type array → constraint-inferred type → `enum` → object/array/tuple/scalar
685
+ * empty-schema fallback (`emptySchemaType` option).
873
686
  */
874
- function convertSchema({ schema, name }: { schema: SchemaObject; name?: string }, options?: Partial<Options>): SchemaNode {
875
- const mergedOptions: Options = { ...DEFAULT_OPTIONS, ...options }
876
- // Flatten keyword-only allOf fragments (no $ref, no structural keys) into the parent
877
- // schema before parsing, so simple annotation patterns don't produce needless intersections.
687
+ function parseSchema({ schema, name }: { schema: SchemaObject; name?: string | null }, rawOptions?: Partial<ast.ParserOptions>): ast.SchemaNode {
688
+ const options: ast.ParserOptions = {
689
+ ...DEFAULT_PARSER_OPTIONS,
690
+ ...rawOptions,
691
+ }
878
692
  const flattenedSchema = flattenSchema(schema)
879
693
  if (flattenedSchema && flattenedSchema !== schema) {
880
- return convertSchema({ schema: flattenedSchema, name }, options)
694
+ return parseSchema({ schema: flattenedSchema, name }, rawOptions)
881
695
  }
882
696
 
883
697
  const nullable = isNullable(schema) || undefined
884
698
  const defaultValue = schema.default === null && nullable ? undefined : schema.default
885
- // Normalize OAS 3.1 multi-type array to a single type string for the dispatch below.
886
699
  const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
887
700
 
888
- const ctx: SchemaContext = { schema, name, nullable, defaultValue, type, options, mergedOptions }
701
+ const ctx: SchemaContext = {
702
+ schema,
703
+ name,
704
+ nullable,
705
+ defaultValue,
706
+ type,
707
+ rawOptions,
708
+ options,
709
+ }
889
710
 
890
- // $ref — pointer to another definition.
891
- // In OAS 3.0 siblings of $ref are technically ignored, but Kubb intentionally preserves them
892
- // so that annotations like `pattern`, `description`, and `nullable` are reflected in generated code.
893
711
  if (isReference(schema)) return convertRef(ctx)
894
712
 
895
- // Composition keywords
896
713
  if (schema.allOf?.length) return convertAllOf(ctx)
897
714
  const unionMembers = [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]
898
715
  if (unionMembers.length) return convertUnion(ctx)
899
716
 
900
- // OAS 3.1 const — a single fixed value, semantically equivalent to a one-item enum.
901
- // `const: undefined` falls through to the empty-type fallback.
902
717
  if ('const' in schema && schema.const !== undefined) return convertConst(ctx)
903
718
 
904
- // Format-based special types take precedence over `type`.
905
- // `convertFormat` returns undefined when format should fall through to string (dateType: false).
906
- // see https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
907
719
  if (schema.format) {
908
720
  const formatResult = convertFormat(ctx)
909
721
  if (formatResult) return formatResult
910
722
  }
911
723
 
912
- // OAS 3.1: `contentMediaType: 'application/octet-stream'` on a string schema signals binary data.
913
724
  if (schema.type === 'string' && schema.contentMediaType === 'application/octet-stream') {
914
- return createSchema({ type: 'blob', primitive: 'string', ...buildSchemaBase(schema, name, nullable, defaultValue) })
725
+ return ast.createSchema({
726
+ type: 'blob',
727
+ primitive: 'string',
728
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
729
+ })
915
730
  }
916
731
 
917
- // OAS 3.1: `type` may be an array — e.g. `["string", "integer", "null"]`.
918
- // `null` in the array is the 3.1 equivalent of `nullable: true`; strip it and set the flag.
919
- // When 2+ non-null types remain, produce a union; when exactly 1 non-null type remains, fall through.
920
732
  if (Array.isArray(schema.type) && schema.type.length > 1) {
921
733
  const nonNullTypes = schema.type.filter((t) => t !== 'null') as string[]
922
734
  const arrayNullable = schema.type.includes('null') || nullable || undefined
923
735
 
924
736
  if (nonNullTypes.length > 1) {
925
- return createSchema({
737
+ return ast.createSchema({
926
738
  type: 'union',
927
- members: nonNullTypes.map((t) => convertSchema({ schema: { ...schema, type: t } as SchemaObject, name }, options)),
928
- ...buildSchemaBase(schema, name, arrayNullable, defaultValue),
739
+ members: nonNullTypes.map((t) => parseSchema({ schema: { ...schema, type: t } as SchemaObject, name }, rawOptions)),
740
+ ...buildSchemaNode(schema, name, arrayNullable, defaultValue),
929
741
  })
930
742
  }
931
743
  }
932
744
 
933
- // Infer type from constraints when no explicit type is provided.
934
- // minLength / maxLength / pattern → string; minimum / maximum → number.
935
- // Note: minItems/maxItems do NOT infer array — arrays require an `items` key.
936
745
  if (!type) {
937
746
  if (schema.minLength !== undefined || schema.maxLength !== undefined || schema.pattern !== undefined) {
938
747
  return convertString(ctx)
@@ -952,74 +761,153 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
952
761
  if (type === 'boolean') return convertBoolean(ctx)
953
762
  if (type === 'null') return convertNull(ctx)
954
763
 
955
- const emptyType = resolveTypeOption(mergedOptions.emptySchemaType)
956
- return createSchema({ type: emptyType as ScalarSchemaType, name, title: schema.title, description: schema.description })
764
+ const emptyType = typeOptionMap.get(options.emptySchemaType)!
765
+ return ast.createSchema({
766
+ type: emptyType as ast.ScalarSchemaType,
767
+ name,
768
+ title: schema.title,
769
+ description: schema.description,
770
+ })
957
771
  }
958
772
 
959
773
  /**
960
- * Converts a single dereferenced OAS parameter object into a `ParameterNode`.
961
- * When the parameter has no `schema` or its schema is a `$ref`, falls back to `unknownType`.
774
+ * Converts a dereferenced OAS parameter object into a `ParameterNode`.
962
775
  */
963
- function parseParameter(options: Options, param: Record<string, unknown>): ParameterNode {
776
+ function parseParameter(options: ast.ParserOptions, param: Record<string, unknown>): ast.ParameterNode {
964
777
  const required = (param['required'] as boolean | undefined) ?? false
965
778
 
966
- const schema: SchemaNode =
967
- param['schema'] && !isReference(param['schema'])
968
- ? convertSchema({ schema: param['schema'] as SchemaObject }, options)
969
- : createSchema({ type: resolveTypeOption(options.unknownType) })
779
+ const schema: ast.SchemaNode = param['schema']
780
+ ? parseSchema({ schema: param['schema'] as SchemaObject }, options)
781
+ : ast.createSchema({ type: typeOptionMap.get(options.unknownType)! })
970
782
 
971
- return createParameter({
783
+ return ast.createParameter({
972
784
  name: param['name'] as string,
973
- in: param['in'] as ParameterLocation,
785
+ in: param['in'] as ast.ParameterLocation,
974
786
  schema: {
975
787
  ...schema,
976
- optional: !required || !!schema.optional ? true : undefined,
788
+ description: (param['description'] as string | undefined) ?? schema.description,
977
789
  },
978
790
  required,
979
791
  })
980
792
  }
981
793
 
982
794
  /**
983
- * Converts an OAS `Operation` into an `OperationNode`, resolving parameters,
984
- * request body, and all response codes into their AST node equivalents.
795
+ * Reads the inline `requestBody` metadata (description / required) that OAS exposes
796
+ * outside the schema itself. Returns an empty object when the request body is missing or a `$ref`.
985
797
  */
986
- function parseOperation(options: Options, oas: Oas, operation: Operation): OperationNode {
987
- const parameters: Array<ParameterNode> = operation.getParameters().map((param) => {
988
- const dereferenced = oas.dereferenceWithRef(param) as unknown as Record<string, unknown>
798
+ function getRequestBodyMeta(operation: Operation): {
799
+ description?: string
800
+ required: boolean
801
+ } {
802
+ const body = operation.schema.requestBody as { description?: string; required?: boolean } | undefined
803
+ if (!body) return { required: false }
804
+
805
+ // After getRequestBodyContentTypes has run, body may still carry $ref but the
806
+ // resolved fields (description, required, content) are already spread onto it.
807
+ return {
808
+ description: body.description,
809
+ required: body.required === true,
810
+ }
811
+ }
989
812
 
990
- return parseParameter(options, dereferenced)
991
- })
813
+ /**
814
+ * Reads the inline response object (not a `$ref`) and returns its description plus its `content` map.
815
+ */
816
+ function getResponseMeta(responseObj: unknown): {
817
+ description?: string
818
+ content?: Record<string, unknown>
819
+ } {
820
+ if (typeof responseObj !== 'object' || responseObj === null || Array.isArray(responseObj)) return {}
821
+
822
+ const inline = responseObj as {
823
+ description?: string
824
+ content?: Record<string, unknown>
825
+ }
826
+ return { description: inline.description, content: inline.content }
827
+ }
992
828
 
993
- const requestBodySchema = oas.getRequestSchema(operation)
994
- const requestBody = requestBodySchema ? convertSchema({ schema: requestBodySchema }, options) : undefined
829
+ /**
830
+ * Collects property names whose schema has a truthy boolean flag (`readOnly` or `writeOnly`).
831
+ * `$ref` entries are skipped since their flags live on the dereferenced target.
832
+ */
833
+ function collectPropertyKeysByFlag(schema: SchemaObject | null, flag: 'readOnly' | 'writeOnly'): string[] | undefined {
834
+ if (!schema?.properties) return undefined
835
+
836
+ const keys: string[] = []
837
+ for (const key in schema.properties) {
838
+ const prop = schema.properties[key]
839
+ if (prop && !isReference(prop) && (prop as Record<string, unknown>)[flag]) {
840
+ keys.push(key)
841
+ }
842
+ }
843
+ return keys.length ? keys : undefined
844
+ }
995
845
 
996
- const responses: Array<ResponseNode> = operation.getResponseStatusCodes().map((statusCode) => {
997
- const responseObj = operation.getResponseByStatusCode(statusCode)
998
- const responseSchema = oas.getResponseSchema(operation, statusCode)
846
+ /**
847
+ * Converts an OAS `Operation` into an `OperationNode`.
848
+ */
849
+ function parseOperation(options: ast.ParserOptions, operation: Operation): ast.OperationNode {
850
+ const parameters: Array<ast.ParameterNode> = getParameters(document, operation).map((param) =>
851
+ parseParameter(options, param as unknown as Record<string, unknown>),
852
+ )
999
853
 
1000
- const schema = responseSchema && Object.keys(responseSchema).length > 0 ? convertSchema({ schema: responseSchema }, options) : undefined
854
+ // Determine which content types to include in requestBody.content.
855
+ // When a global contentType is configured, restrict to that single type.
856
+ // Otherwise include every content type declared in the spec.
857
+ const allContentTypes = ctx.contentType ? [ctx.contentType] : getRequestBodyContentTypes(document, operation)
858
+
859
+ const requestBodyMeta = getRequestBodyMeta(operation)
860
+
861
+ const content = allContentTypes.flatMap((ct) => {
862
+ const schema = getRequestSchema(document, operation, { contentType: ct })
863
+ if (!schema) return []
864
+ return [
865
+ {
866
+ contentType: ct,
867
+ schema: ast.syncOptionality(parseSchema({ schema }, options), requestBodyMeta.required),
868
+ keysToOmit: collectPropertyKeysByFlag(schema, 'readOnly'),
869
+ },
870
+ ]
871
+ })
1001
872
 
1002
- const description = typeof responseObj === 'object' && responseObj !== null && !Array.isArray(responseObj) ? responseObj.description : undefined
873
+ const requestBody =
874
+ content.length > 0 || requestBodyMeta.description
875
+ ? {
876
+ description: requestBodyMeta.description,
877
+ required: requestBodyMeta.required || undefined,
878
+ content: content.length > 0 ? content : undefined,
879
+ }
880
+ : undefined
1003
881
 
1004
- const rawContent =
1005
- typeof responseObj === 'object' && responseObj !== null && !Array.isArray(responseObj)
1006
- ? (responseObj as { content?: Record<string, unknown> }).content
1007
- : undefined
882
+ const responses: Array<ast.ResponseNode> = operation.getResponseStatusCodes().map((statusCode) => {
883
+ const responseObj = operation.getResponseByStatusCode(statusCode)
884
+ const responseSchema = getResponseSchema(document, operation, statusCode, { contentType: ctx.contentType })
1008
885
 
1009
- const mediaType = rawContent ? toMediaType(Object.keys(rawContent)[0] ?? '') : toMediaType(operation.contentType ?? '')
886
+ const schema =
887
+ responseSchema && Object.keys(responseSchema).length > 0
888
+ ? parseSchema({ schema: responseSchema }, options)
889
+ : ast.createSchema({
890
+ type: typeOptionMap.get(options.emptySchemaType)!,
891
+ })
1010
892
 
1011
- return createResponse({
1012
- statusCode: statusCode as StatusCode,
893
+ const { description, content } = getResponseMeta(responseObj)
894
+ const mediaType = content ? getMediaType(Object.keys(content)[0] ?? '') : getMediaType(operation.contentType ?? '')
895
+
896
+ return ast.createResponse({
897
+ statusCode: statusCode as ast.StatusCode,
1013
898
  description,
1014
899
  schema,
1015
900
  mediaType,
901
+ keysToOmit: collectPropertyKeysByFlag(responseSchema, 'writeOnly'),
1016
902
  })
1017
903
  })
1018
904
 
1019
- return createOperation({
905
+ const urlPath = new URLPath(operation.path)
906
+
907
+ return ast.createOperation({
1020
908
  operationId: operation.getOperationId(),
1021
- method: operation.method.toUpperCase() as HttpMethod,
1022
- path: new URLPath(operation.path).URL,
909
+ method: operation.method.toUpperCase() as ast.HttpMethod,
910
+ path: urlPath.path,
1023
911
  tags: operation.getTags().map((tag) => tag.name),
1024
912
  summary: operation.getSummary() || undefined,
1025
913
  description: operation.getDescription() || undefined,
@@ -1030,63 +918,66 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1030
918
  })
1031
919
  }
1032
920
 
1033
- /**
1034
- * Converts an OpenAPI/Swagger spec (wrapped in a Kubb `Oas` instance) into
1035
- * a `RootNode` — the top-level node of the `@kubb/ast` tree.
1036
- */
1037
- function parse<TOptions extends Partial<Options> = object>(options?: TOptions): RootNode {
1038
- const mergedOptions: Options = { ...DEFAULT_OPTIONS, ...options }
921
+ return { parseSchema, parseOperation, parseParameter }
922
+ }
1039
923
 
1040
- const schemas: Array<SchemaNode> = Object.entries(schemaObjects).map(([name, schemaObject]) =>
1041
- convertSchema({ schema: schemaObject as SchemaObject, name }, mergedOptions),
1042
- )
924
+ /**
925
+ * Converts a single `SchemaObject` into a `SchemaNode`.
926
+ *
927
+ * @example
928
+ * ```ts
929
+ * const ctx = { document }
930
+ * parseSchema(ctx, { schema: { type: 'string', format: 'uuid' } })
931
+ * ```
932
+ */
933
+ export function parseSchema(
934
+ ctx: OasParserContext,
935
+ { schema, name }: { schema: SchemaObject; name?: string },
936
+ options?: Partial<ast.ParserOptions>,
937
+ ): ast.SchemaNode {
938
+ return createSchemaParser(ctx).parseSchema({ schema, name }, options)
939
+ }
1043
940
 
1044
- const paths = oas.getPaths()
941
+ /**
942
+ * Converts the entire OpenAPI spec into an `InputNode` (the top-level `@kubb/ast` tree).
943
+ *
944
+ * This is the main entry point: `OpenAPI / Swagger → Kubb AST`.
945
+ * No code is generated here — the resulting tree is spec-agnostic and consumed by
946
+ * downstream plugins (`plugin-ts`, `plugin-zod`, …).
947
+ *
948
+ * @example
949
+ * ```ts
950
+ * const document = await parseFromConfig(config)
951
+ * const root = parseOas(document, { dateType: 'date', contentType: 'application/json' })
952
+ * ```
953
+ */
954
+ export function parseOas(
955
+ document: Document,
956
+ options: Partial<ast.ParserOptions> & { contentType?: ContentType } = {},
957
+ ): { root: ast.InputNode; nameMapping: Map<string, string> } {
958
+ const { contentType, ...parserOptions } = options
959
+ const mergedOptions: ast.ParserOptions = {
960
+ ...DEFAULT_PARSER_OPTIONS,
961
+ ...parserOptions,
962
+ }
1045
963
 
1046
- const operations: Array<OperationNode> = Object.entries(paths).flatMap(([_path, methods]) =>
1047
- Object.entries(methods)
1048
- .map(([, operation]) => (operation ? parseOperation(mergedOptions, oas, operation) : null))
1049
- .filter((op): op is OperationNode => op !== null),
1050
- )
964
+ const { schemas: schemaObjects, nameMapping } = getSchemas(document, {
965
+ contentType,
966
+ })
967
+ const { parseSchema: _parseSchema, parseOperation: _parseOperation } = createSchemaParser({ document, contentType })
1051
968
 
1052
- return createRoot({ schemas, operations })
1053
- }
969
+ const schemas: Array<ast.SchemaNode> = Object.entries(schemaObjects).map(([name, schema]) => _parseSchema({ schema, name }, mergedOptions))
1054
970
 
1055
- /**
1056
- * Walks a `SchemaNode` tree and resolves all `ref` node names through the provided callbacks.
1057
- *
1058
- * `resolveName` handles all schema types; `resolveEnumName` (when provided) takes precedence
1059
- * for `enum` nodes, enabling a separate naming strategy for enums (e.g. different suffix).
1060
- *
1061
- * Collision-resolved names (from `nameMapping`) are applied before user-supplied resolvers.
1062
- */
1063
- function resolveRefs(node: SchemaNode, resolveName: (ref: string) => string | undefined, resolveEnumName?: (name: string) => string | undefined): SchemaNode {
1064
- return transform(node, {
1065
- schema(schemaNode) {
1066
- const schemaRef = narrowSchema(schemaNode, schemaTypes.ref)
1067
-
1068
- if (schemaRef && (schemaRef.ref || schemaRef.name)) {
1069
- const rawRef = schemaRef.ref ?? schemaRef.name!
1070
- const resolved = resolveName(nameMapping.get(rawRef) ?? rawRef)
1071
- if (resolved) {
1072
- return { ...schemaNode, name: resolved }
1073
- }
1074
- }
971
+ const baseOas = new BaseOas(document)
972
+ const paths = baseOas.getPaths()
1075
973
 
1076
- if (schemaNode.type === 'enum' && schemaNode.name) {
1077
- const resolved = (resolveEnumName ?? resolveName)(schemaNode.name)
1078
- if (resolved) {
1079
- return { ...schemaNode, name: resolved }
1080
- }
1081
- }
1082
- },
1083
- }) as SchemaNode
1084
- }
974
+ const operations: Array<ast.OperationNode> = Object.entries(paths).flatMap(([_path, methods]) =>
975
+ Object.entries(methods)
976
+ .map(([, operation]) => (operation ? _parseOperation(mergedOptions, operation) : null))
977
+ .filter((op): op is ast.OperationNode => op !== null),
978
+ )
979
+
980
+ const root = ast.createInput({ schemas, operations })
1085
981
 
1086
- return {
1087
- parse,
1088
- convertSchema,
1089
- resolveRefs,
1090
- nameMapping,
1091
- } as OasParser
982
+ return { root, nameMapping }
1092
983
  }