@kubb/adapter-oas 5.0.0-alpha.9 → 5.0.0-beta.2

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,369 +1,131 @@
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
+ * Parser context holding the raw OpenAPI document and optional content-type override.
24
+ *
25
+ * Passed to schema and operation converters to access the full specification
26
+ * and handle content negotiation when multiple media types are available.
184
27
  */
185
- function toMediaType(contentType: string): MediaType | undefined {
186
- return knownMediaTypes.has(contentType as MediaType) ? (contentType as MediaType) : undefined
28
+ export type OasParserContext = {
29
+ document: Document
30
+ contentType?: ContentType
187
31
  }
188
32
 
189
33
  /**
190
- * Pre-computed per-schema context passed to every `convert*` branch handler.
191
- * Grouping these values avoids repeating the same derivations across all branches.
34
+ * Pre-computed per-schema context passed to every schema converter.
35
+ *
36
+ * Centralizes schema derivations (type resolution, defaults, options) to avoid repeated
37
+ * computation across all conversion branches. The `type` field is normalized from OAS 3.1
38
+ * multi-type arrays to a single string.
192
39
  */
193
40
  type SchemaContext = {
194
41
  schema: SchemaObject
195
- name: string | undefined
42
+ name: string | null | undefined
196
43
  nullable: true | undefined
197
44
  defaultValue: unknown
198
45
  /**
199
46
  * Normalized single type string (first element when OAS 3.1 multi-type array).
200
47
  */
201
48
  type: string | undefined
202
- options: Partial<Options> | undefined
203
- mergedOptions: Options
49
+ rawOptions: Partial<ast.ParserOptions> | undefined
50
+ options: ast.ParserOptions
204
51
  }
205
52
 
206
53
  /**
207
- * The public interface returned by `createOasParser`.
54
+ * Normalizes malformed `{ type: 'array', enum: [...] }` schemas by moving enum values into items.
55
+ *
56
+ * This pattern violates the OpenAPI spec but appears in real specs. The fix moves enum values
57
+ * from the array to its items sub-schema, making them valid for downstream processing.
58
+ *
59
+ * @note This is a defensive measure for robustness with non-compliant specs.
208
60
  */
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
61
+ function normalizeArrayEnum(schema: SchemaObject): SchemaObject {
62
+ const isItemsObject = typeof schema.items === 'object' && !Array.isArray(schema.items)
63
+ const normalizedItems: SchemaObject = {
64
+ ...(isItemsObject ? (schema.items as SchemaObject) : {}),
65
+ enum: schema.enum,
66
+ }
67
+ const { enum: _enum, ...schemaWithoutEnum } = schema
228
68
 
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>
69
+ return { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject
237
70
  }
238
71
 
239
72
  /**
240
- * Creates an OAS parser that converts an OpenAPI/Swagger spec into
241
- * the `@kubb/ast` tree.
73
+ * Factory function that creates schema and operation converters for a given OpenAPI context.
242
74
  *
243
- * Options are passed per-call to `parse` or `convertSchema` rather than
244
- * at construction time, keeping the factory lightweight.
75
+ * Returns closures that share mutable state (`resolvingRefs` set for cycle detection).
76
+ * Each converter branch (`convertRef`, `convertAllOf`, etc.) mutually recursively calls `parseSchema`,
77
+ * made possible by hoisting of function declarations.
245
78
  *
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
- * ```
79
+ * @note Not exported; called internally by `parseOas()` and `parseSchema()`.
257
80
  */
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 })
81
+ function createSchemaParser(ctx: OasParserContext) {
82
+ const document = ctx.document
83
+
84
+ // Branch handlers each converts one OAS schema pattern to a SchemaNode.
262
85
 
263
86
  /**
264
- * Maps an `'any' | 'unknown' | 'void'` option string to the corresponding `SchemaType` constant.
265
- * Used for both `unknownType` (unannotated schemas) and `emptySchemaType` (empty `{}` schemas).
87
+ * Tracks `$ref` paths that are currently being resolved to prevent infinite
88
+ * recursion when schemas contain circular references (e.g. `Pet parent → Pet`).
266
89
  */
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
- }
90
+ const resolvingRefs = new Set<string>()
272
91
 
273
92
  /**
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`.
93
+ * Converts a `$ref` schema into a `RefSchemaNode`.
94
+ *
95
+ * The resolved schema is stored in `node.schema`. Usage-site sibling fields
96
+ * (description, readOnly, nullable, etc.) are stored directly on the ref node.
97
+ * Use `syncSchemaRef(node)` in printers to get a merged view of both.
98
+ * Circular refs are detected via `resolvingRefs` and leave `schema` as `undefined`.
276
99
  */
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 }
100
+ function convertRef({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
101
+ let resolvedSchema: ast.SchemaNode | undefined
102
+ const refPath = schema.$ref
103
+ if (refPath && !resolvingRefs.has(refPath)) {
104
+ try {
105
+ const referenced = resolveRef<SchemaObject>(document, refPath)
106
+ if (referenced) {
107
+ resolvingRefs.add(refPath)
108
+ resolvedSchema = parseSchema({ schema: referenced }, rawOptions)
109
+ resolvingRefs.delete(refPath)
110
+ }
111
+ } catch {
112
+ // Ref cannot be resolved in this document (e.g. unit tests with minimal documents).
294
113
  }
295
- return { type: 'datetime', offset: false }
296
114
  }
297
115
 
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({
116
+ return ast.createSchema({
117
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
337
118
  type: 'ref',
338
- name: extractRefName(schema.$ref!),
119
+ name: ast.extractRefName(schema.$ref!),
339
120
  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,
121
+ schema: resolvedSchema,
348
122
  })
349
123
  }
350
124
 
351
125
  /**
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.
126
+ * Converts an `allOf` schema into a flattened node or an `IntersectionSchemaNode`.
365
127
  */
366
- function convertAllOf({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
128
+ function convertAllOf({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
367
129
  if (
368
130
  schema.allOf!.length === 1 &&
369
131
  !schema.properties &&
@@ -371,12 +133,12 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
371
133
  schema.additionalProperties === undefined
372
134
  ) {
373
135
  const [memberSchema] = schema.allOf as Array<SchemaObject | ReferenceObject>
374
- const memberNode = convertSchema({ schema: memberSchema! as SchemaObject }, options)
136
+ const memberNode = parseSchema({ schema: memberSchema! as SchemaObject, name: null }, rawOptions)
375
137
  const { kind: _kind, ...memberNodeProps } = memberNode
376
138
  const mergedNullable = nullable || memberNode.nullable || undefined
377
139
  const mergedDefault = schema.default === null && mergedNullable ? undefined : (schema.default ?? memberNode.default)
378
140
 
379
- return createSchema({
141
+ return ast.createSchema({
380
142
  ...memberNodeProps,
381
143
  name,
382
144
  title: schema.title ?? memberNode.title,
@@ -388,31 +150,39 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
388
150
  default: mergedDefault,
389
151
  example: schema.example ?? memberNode.example,
390
152
  pattern: schema.pattern ?? ('pattern' in memberNode ? memberNode.pattern : undefined),
391
- } as DistributiveOmit<SchemaNode, 'kind'>)
153
+ } as ast.DistributiveOmit<ast.SchemaNode, 'kind'>)
392
154
  }
393
155
 
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>)
156
+ const filteredDiscriminantValues: Array<{
157
+ propertyName: string
158
+ value: string
159
+ }> = []
160
+ const allOfMembers: Array<ast.SchemaNode> = (schema.allOf as Array<SchemaObject | ReferenceObject>)
397
161
  .filter((item) => {
398
162
  if (!isReference(item) || !name) return true
399
- const deref = oas.get<SchemaObject>(item.$ref)
163
+ const deref = resolveRef<SchemaObject>(document, item.$ref)
400
164
  if (!deref || !isDiscriminator(deref)) return true
401
165
  const parentUnion = deref.oneOf ?? deref.anyOf
402
166
  if (!parentUnion) return true
403
- const childRef = `#/components/schemas/${name}`
167
+ const childRef = `${SCHEMA_REF_PREFIX}${name}`
404
168
  const inOneOf = parentUnion.some((oneOfItem) => isReference(oneOfItem) && oneOfItem.$ref === childRef)
405
169
  const inMapping = Object.values(deref.discriminator.mapping ?? {}).some((v) => v === childRef)
406
- return !inOneOf && !inMapping
170
+ if (inOneOf || inMapping) {
171
+ const discriminatorValue = ast.findDiscriminator(deref.discriminator.mapping, childRef)
172
+ if (discriminatorValue) {
173
+ filteredDiscriminantValues.push({
174
+ propertyName: deref.discriminator.propertyName,
175
+ value: discriminatorValue,
176
+ })
177
+ }
178
+ return false
179
+ }
180
+ return true
407
181
  })
408
- .map((s) => convertSchema({ schema: s as SchemaObject }, options))
182
+ .map((s) => parseSchema({ schema: s as SchemaObject }, rawOptions))
409
183
 
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
184
  const syntheticStart = allOfMembers.length
413
185
 
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
186
  if (Array.isArray(schema.required) && schema.required.length) {
417
187
  const outerKeys = schema.properties ? new Set(Object.keys(schema.properties)) : new Set<string>()
418
188
  const missingRequired = schema.required.filter((key) => !outerKeys.has(key))
@@ -420,14 +190,24 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
420
190
  if (missingRequired.length) {
421
191
  const resolvedMembers = (schema.allOf as Array<SchemaObject | ReferenceObject>).flatMap((item) => {
422
192
  if (!isReference(item)) return [item as SchemaObject]
423
- const deref = oas.get<SchemaObject>(item.$ref)
193
+ const deref = resolveRef<SchemaObject>(document, item.$ref)
424
194
  return deref && !isReference(deref) ? [deref] : []
425
195
  })
426
196
 
427
197
  for (const key of missingRequired) {
428
198
  for (const resolved of resolvedMembers) {
429
199
  if (resolved.properties?.[key]) {
430
- allOfMembers.push(convertSchema({ schema: { properties: { [key]: resolved.properties[key] }, required: [key] } as SchemaObject }, options))
200
+ allOfMembers.push(
201
+ parseSchema(
202
+ {
203
+ schema: {
204
+ properties: { [key]: resolved.properties[key] },
205
+ required: [key],
206
+ } as SchemaObject,
207
+ },
208
+ rawOptions,
209
+ ),
210
+ )
431
211
  break
432
212
  }
433
213
  }
@@ -437,110 +217,151 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
437
217
 
438
218
  if (schema.properties) {
439
219
  const { allOf: _allOf, ...schemaWithoutAllOf } = schema
440
- allOfMembers.push(convertSchema({ schema: schemaWithoutAllOf }, options))
220
+ allOfMembers.push(parseSchema({ schema: schemaWithoutAllOf }, rawOptions))
441
221
  }
442
222
 
443
- // Merge consecutive anonymous object members within the synthetic portion — see `mergeAdjacentAnonymousObjects`.
444
- return createSchema({
223
+ for (const { propertyName, value } of filteredDiscriminantValues) {
224
+ allOfMembers.push(ast.createDiscriminantNode({ propertyName, value }))
225
+ }
226
+
227
+ return ast.createSchema({
445
228
  type: 'intersection',
446
- members: [...allOfMembers.slice(0, syntheticStart), ...mergeAdjacentAnonymousObjects(allOfMembers.slice(syntheticStart))],
447
- ...buildSchemaBase(schema, name, nullable, defaultValue),
229
+ members: [...ast.mergeAdjacentObjects(allOfMembers.slice(0, syntheticStart)), ...ast.mergeAdjacentObjects(allOfMembers.slice(syntheticStart))],
230
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
448
231
  })
449
232
  }
450
233
 
451
234
  /**
452
235
  * 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
236
  */
459
- function convertUnion({ schema, name, nullable, defaultValue, options }: SchemaContext): SchemaNode {
237
+ function convertUnion({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
238
+ function pickDiscriminatorPropertyNode(node: ast.SchemaNode, propertyName: string): ast.SchemaNode | null {
239
+ const objectNode = ast.narrowSchema(node, 'object')
240
+ const discriminatorProperty = objectNode?.properties?.find((property) => property.name === propertyName)
241
+
242
+ if (!discriminatorProperty) {
243
+ return null
244
+ }
245
+
246
+ return ast.createSchema({
247
+ type: 'object',
248
+ primitive: 'object',
249
+ properties: [discriminatorProperty],
250
+ })
251
+ }
252
+
460
253
  const unionMembers = [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]
254
+ const strategy: 'one' | 'any' = schema.oneOf ? 'one' : 'any'
461
255
  const unionBase = {
462
- ...buildSchemaBase(schema, name, nullable, defaultValue),
256
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
463
257
  discriminatorPropertyName: isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
258
+ strategy,
464
259
  }
260
+ const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
261
+ const sharedPropertiesNode = schema.properties
262
+ ? (() => {
263
+ const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
264
+ const memberBaseSchema: SchemaObject = discriminator
265
+ ? (Object.fromEntries(Object.entries(schemaWithoutUnion).filter(([key]) => key !== 'discriminator')) as SchemaObject)
266
+ : schemaWithoutUnion
267
+ return parseSchema({ schema: memberBaseSchema, name }, rawOptions)
268
+ })()
269
+ : undefined
465
270
 
466
- if (schema.properties) {
467
- const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
468
- const discriminator = isDiscriminator(schema) ? schema.discriminator : undefined
271
+ if (sharedPropertiesNode || discriminator?.mapping) {
272
+ const members = unionMembers.map((s) => {
273
+ const ref = isReference(s) ? s.$ref : undefined
274
+ const discriminatorValue = ast.findDiscriminator(discriminator?.mapping, ref)
275
+ const memberNode = parseSchema({ schema: s as SchemaObject }, rawOptions)
469
276
 
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
277
+ if (!discriminatorValue || !discriminator) {
278
+ return memberNode
279
+ }
280
+
281
+ const narrowedDiscriminatorNode = sharedPropertiesNode
282
+ ? pickDiscriminatorPropertyNode(
283
+ ast.setDiscriminatorEnum({
284
+ node: sharedPropertiesNode,
285
+ propertyName: discriminator.propertyName,
286
+ values: [discriminatorValue],
287
+ }),
288
+ discriminator.propertyName,
289
+ )
290
+ : undefined
474
291
 
475
- return createSchema({
292
+ return ast.createSchema({
293
+ type: 'intersection',
294
+ members: [
295
+ memberNode,
296
+ narrowedDiscriminatorNode ??
297
+ ast.createDiscriminantNode({
298
+ propertyName: discriminator.propertyName,
299
+ value: discriminatorValue,
300
+ }),
301
+ ],
302
+ })
303
+ })
304
+
305
+ const unionNode = ast.createSchema({
476
306
  type: 'union',
477
307
  ...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)
308
+ members,
309
+ })
483
310
 
484
- if (discriminatorValue && discriminator) {
485
- propertiesNode = applyDiscriminatorEnum({ node: propertiesNode, propertyName: discriminator.propertyName, values: [discriminatorValue] })
486
- }
311
+ if (!sharedPropertiesNode) {
312
+ return unionNode
313
+ }
487
314
 
488
- return createSchema({
489
- type: 'intersection',
490
- members: [convertSchema({ schema: s as SchemaObject }, options), propertiesNode],
491
- })
492
- }),
315
+ return ast.createSchema({
316
+ type: 'intersection',
317
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
318
+ members: [unionNode, sharedPropertiesNode],
493
319
  })
494
320
  }
495
321
 
496
- return createSchema({
322
+ return ast.createSchema({
497
323
  type: 'union',
498
324
  ...unionBase,
499
- members: simplifyUnionMembers(unionMembers.map((s) => convertSchema({ schema: s as SchemaObject }, options))),
325
+ members: ast.simplifyUnion(unionMembers.map((s) => parseSchema({ schema: s as SchemaObject }, rawOptions))),
500
326
  })
501
327
  }
502
328
 
503
329
  /**
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.
330
+ * Converts an OAS 3.1 `const` schema into a null scalar or a single-value `EnumSchemaNode`.
507
331
  */
508
- function convertConst({ schema, name, nullable, defaultValue }: SchemaContext): SchemaNode {
332
+ function convertConst({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
509
333
  const constValue = schema.const
510
334
 
511
335
  if (constValue === null) {
512
- return createSchema({
336
+ return ast.createSchema({
513
337
  type: 'null',
514
338
  primitive: 'null',
515
339
  name,
516
340
  title: schema.title,
517
341
  description: schema.description,
518
342
  deprecated: schema.deprecated,
519
- nullable,
520
343
  })
521
344
  }
522
345
 
523
346
  const constPrimitive = getPrimitiveType(typeof constValue === 'number' ? 'number' : typeof constValue === 'boolean' ? 'boolean' : 'string')
524
- return createSchema({
347
+ return ast.createSchema({
525
348
  type: 'enum',
526
349
  primitive: constPrimitive,
527
350
  enumValues: [constValue as string | number | boolean],
528
- ...buildSchemaBase(schema, name, nullable, defaultValue),
351
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
529
352
  })
530
353
  }
531
354
 
532
355
  /**
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`).
356
+ * Converts a format-annotated schema into a special-type `SchemaNode`.
357
+ * Returns `null` when the format should fall through to string handling (`dateType: false`).
536
358
  */
537
- function convertFormat({ schema, name, nullable, defaultValue, mergedOptions }: SchemaContext): SchemaNode | undefined {
538
- const base = buildSchemaBase(schema, name, nullable, defaultValue)
359
+ function convertFormat({ schema, name, nullable, defaultValue, options }: SchemaContext): ast.SchemaNode | null {
360
+ const base = buildSchemaNode(schema, name, nullable, defaultValue)
539
361
 
540
- // int64 is option-dependent so it can't live in the static formatMap.
541
362
  if (schema.format === 'int64') {
542
- return createSchema({
543
- type: mergedOptions.integerType === 'bigint' ? 'bigint' : 'integer',
363
+ return ast.createSchema({
364
+ type: options.integerType === 'bigint' ? 'bigint' : 'integer',
544
365
  primitive: 'integer',
545
366
  ...base,
546
367
  min: schema.minimum,
@@ -550,52 +371,87 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
550
371
  })
551
372
  }
552
373
 
553
- // date-time / date / time are option-dependent and can't live in the static formatMap.
554
374
  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
375
+ const dateType = getDateType(options, schema.format)
376
+ if (!dateType) return null
557
377
 
558
378
  if (dateType.type === 'datetime') {
559
- return createSchema({ ...base, primitive: 'string' as const, type: 'datetime', offset: dateType.offset, local: dateType.local })
379
+ return ast.createSchema({
380
+ ...base,
381
+ primitive: 'string' as const,
382
+ type: 'datetime',
383
+ offset: dateType.offset,
384
+ local: dateType.local,
385
+ })
560
386
  }
561
- return createSchema({ ...base, primitive: 'string' as const, type: dateType.type, representation: dateType.representation })
387
+ return ast.createSchema({
388
+ ...base,
389
+ primitive: 'string' as const,
390
+ type: dateType.type,
391
+ representation: dateType.representation,
392
+ })
562
393
  }
563
394
 
564
- const specialType = formatToSchemaType(schema.format!)
565
- if (!specialType) return undefined
395
+ const specialType = getSchemaType(schema.format!)
396
+ if (!specialType) return null
566
397
 
567
- const specialPrimitive: PrimitiveSchemaType = specialType === 'number' || specialType === 'integer' || specialType === 'bigint' ? specialType : 'string'
398
+ const specialPrimitive: ast.PrimitiveSchemaType = specialType === 'number' || specialType === 'integer' || specialType === 'bigint' ? specialType : 'string'
568
399
 
569
400
  if (specialType === 'number' || specialType === 'integer' || specialType === 'bigint') {
570
- return createSchema({ ...base, primitive: specialPrimitive, type: specialType })
401
+ return ast.createSchema({
402
+ ...base,
403
+ primitive: specialPrimitive,
404
+ type: specialType,
405
+ })
571
406
  }
572
407
  if (specialType === 'url') {
573
- return createSchema({ ...base, primitive: 'string' as const, type: 'url' })
408
+ return ast.createSchema({
409
+ ...base,
410
+ primitive: 'string' as const,
411
+ type: 'url',
412
+ min: schema.minLength,
413
+ max: schema.maxLength,
414
+ })
415
+ }
416
+ if (specialType === 'ipv4') {
417
+ return ast.createSchema({
418
+ ...base,
419
+ primitive: 'string' as const,
420
+ type: 'ipv4',
421
+ })
422
+ }
423
+ if (specialType === 'ipv6') {
424
+ return ast.createSchema({
425
+ ...base,
426
+ primitive: 'string' as const,
427
+ type: 'ipv6',
428
+ })
429
+ }
430
+ if (specialType === 'uuid' || specialType === 'email') {
431
+ return ast.createSchema({
432
+ ...base,
433
+ primitive: 'string' as const,
434
+ type: specialType,
435
+ min: schema.minLength,
436
+ max: schema.maxLength,
437
+ })
574
438
  }
575
439
 
576
- return createSchema({ ...base, primitive: specialPrimitive, type: specialType as ScalarSchemaType })
440
+ return ast.createSchema({
441
+ ...base,
442
+ primitive: specialPrimitive,
443
+ type: specialType as ast.ScalarSchemaType,
444
+ })
577
445
  }
578
446
 
579
447
  /**
580
448
  * 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
449
  */
589
- function convertEnum({ schema, name, nullable, type, options }: SchemaContext): SchemaNode {
590
- // Malformed schema: `{ type: 'array', enum: [...] }` — normalize by moving the enum into items.
450
+ function convertEnum({ schema, name, nullable, type, rawOptions }: SchemaContext): ast.SchemaNode {
591
451
  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)
452
+ return parseSchema({ schema: normalizeArrayEnum(schema), name }, rawOptions)
596
453
  }
597
454
 
598
- // `null` in enum values is the OAS 3.0 convention for a nullable enum.
599
455
  const nullInEnum = schema.enum!.includes(null)
600
456
  const filteredValues = (nullInEnum ? schema.enum!.filter((v) => v !== null) : schema.enum!) as Array<string | number | boolean>
601
457
  const enumNullable = nullable || nullInEnum || undefined
@@ -616,93 +472,66 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
616
472
  example: schema.example,
617
473
  }
618
474
 
619
- // x-enumNames / x-enum-varnames: named variants with explicit labels take priority.
620
475
  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({
632
- ...enumBase,
633
- enumType,
634
- namedEnumValues: uniqueNames.map((label, index) => ({
635
- name: String(label),
636
- value: filteredValues[index] ?? label,
637
- format: enumType,
638
- })),
639
- })
640
- }
641
-
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({
476
+ if (extensionKey || enumPrimitive === 'number' || enumPrimitive === 'integer' || enumPrimitive === 'boolean') {
477
+ const enumPrimitiveType = (enumPrimitive === 'number' || enumPrimitive === 'integer' ? 'number' : enumPrimitive === 'boolean' ? 'boolean' : 'string') as
478
+ | 'number'
479
+ | 'boolean'
480
+ | 'string'
481
+ const rawEnumNames = extensionKey ? ((schema as Record<string, unknown>)[extensionKey] as Array<string | number>) : undefined
482
+ const uniqueValues = [...new Set(filteredValues)]
483
+ const seenNames = new Set<string>()
484
+
485
+ return ast.createSchema({
658
486
  ...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
- })),
487
+ primitive: enumPrimitiveType,
488
+ namedEnumValues: uniqueValues
489
+ .map((value, index) => ({
490
+ name: String(rawEnumNames?.[index] ?? value),
491
+ value,
492
+ primitive: enumPrimitiveType,
493
+ }))
494
+ .filter((entry) => {
495
+ if (seenNames.has(entry.name)) return false
496
+ seenNames.add(entry.name)
497
+ return true
498
+ }),
665
499
  })
666
500
  }
667
501
 
668
- // Plain string enum (default path).
669
- return createSchema({
502
+ return ast.createSchema({
670
503
  ...enumBase,
671
504
  enumValues: [...new Set(filteredValues)],
672
505
  })
673
506
  }
674
507
 
675
508
  /**
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`
509
+ * Converts an object-like schema into an `ObjectSchemaNode`.
686
510
  */
687
- function convertObject({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
688
- const properties: Array<PropertyNode> = schema.properties
511
+ function convertObject({ schema, name, nullable, defaultValue, rawOptions, options }: SchemaContext): ast.SchemaNode {
512
+ const properties: Array<ast.PropertyNode> = schema.properties
689
513
  ? Object.entries(schema.properties).map(([propName, propSchema]) => {
690
514
  const required = Array.isArray(schema.required) ? schema.required.includes(propName) : !!schema.required
691
515
  const resolvedPropSchema = propSchema as SchemaObject
692
516
  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
517
 
699
- return createProperty({
518
+ const resolvedChildName = ast.childName(name, propName)
519
+ const propNode = parseSchema({ schema: resolvedPropSchema, name: resolvedChildName }, rawOptions)
520
+ let schemaNode = ast.setEnumName(propNode, name, propName, options.enumSuffix)
521
+
522
+ const tupleNode = ast.narrowSchema(schemaNode, 'tuple')
523
+ if (tupleNode?.items) {
524
+ const namedItems = tupleNode.items.map((item) => ast.setEnumName(item, name, propName, options.enumSuffix))
525
+ if (namedItems.some((item, i) => item !== tupleNode.items![i])) {
526
+ schemaNode = { ...tupleNode, items: namedItems }
527
+ }
528
+ }
529
+
530
+ return ast.createProperty({
700
531
  name: propName,
701
532
  schema: {
702
533
  ...schemaNode,
703
- nullable: propNullable || undefined,
704
- optional: !required && !propNullable ? true : undefined,
705
- nullish: !required && propNullable ? true : undefined,
534
+ nullable: schemaNode.type === 'null' ? undefined : propNullable || undefined,
706
535
  },
707
536
  required,
708
537
  })
@@ -710,15 +539,17 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
710
539
  : []
711
540
 
712
541
  const additionalProperties = schema.additionalProperties
713
- let additionalPropertiesNode: SchemaNode | true | undefined
542
+ let additionalPropertiesNode: ast.SchemaNode | boolean | undefined
714
543
  if (additionalProperties === true) {
715
544
  additionalPropertiesNode = true
716
545
  } else if (additionalProperties && Object.keys(additionalProperties).length > 0) {
717
- additionalPropertiesNode = convertSchema({ schema: additionalProperties as SchemaObject }, options)
546
+ additionalPropertiesNode = parseSchema({ schema: additionalProperties as SchemaObject }, rawOptions)
718
547
  } else if (additionalProperties === false) {
719
- additionalPropertiesNode = undefined
548
+ additionalPropertiesNode = false
720
549
  } else if (additionalProperties) {
721
- additionalPropertiesNode = createSchema({ type: resolveTypeOption(mergedOptions.unknownType) })
550
+ additionalPropertiesNode = ast.createSchema({
551
+ type: typeOptionMap.get(options.unknownType)!,
552
+ })
722
553
  }
723
554
 
724
555
  const rawPatternProperties = 'patternProperties' in schema ? schema.patternProperties : undefined
@@ -728,28 +559,35 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
728
559
  Object.entries(rawPatternProperties).map(([pattern, patternSchema]) => [
729
560
  pattern,
730
561
  patternSchema === true || (typeof patternSchema === 'object' && Object.keys(patternSchema).length === 0)
731
- ? createSchema({ type: resolveTypeOption(mergedOptions.unknownType) })
732
- : convertSchema({ schema: patternSchema as SchemaObject }, options),
562
+ ? ast.createSchema({
563
+ type: typeOptionMap.get(options.unknownType)!,
564
+ })
565
+ : parseSchema({ schema: patternSchema as SchemaObject }, rawOptions),
733
566
  ]),
734
567
  )
735
568
  : undefined
736
569
 
737
- const objectNode: SchemaNode = createSchema({
570
+ const objectNode: ast.SchemaNode = ast.createSchema({
738
571
  type: 'object',
739
572
  primitive: 'object',
740
573
  properties,
741
574
  additionalProperties: additionalPropertiesNode,
742
575
  patternProperties,
743
- ...buildSchemaBase(schema, name, nullable, defaultValue),
576
+ minProperties: schema.minProperties,
577
+ maxProperties: schema.maxProperties,
578
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
744
579
  })
745
580
 
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
581
  if (isDiscriminator(schema) && schema.discriminator.mapping) {
749
582
  const discPropName = schema.discriminator.propertyName
750
583
  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 })
584
+ const enumName = name ? ast.enumPropName(name, discPropName, options.enumSuffix) : undefined
585
+ return ast.setDiscriminatorEnum({
586
+ node: objectNode,
587
+ propertyName: discPropName,
588
+ values,
589
+ enumName,
590
+ })
753
591
  }
754
592
 
755
593
  return objectNode
@@ -757,94 +595,87 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
757
595
 
758
596
  /**
759
597
  * 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
598
  */
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
599
+ function convertTuple({ schema, name, nullable, defaultValue, rawOptions }: SchemaContext): ast.SchemaNode {
600
+ const tupleItems = (schema.prefixItems ?? []).map((item) => parseSchema({ schema: item as SchemaObject }, rawOptions))
601
+ const rest = schema.items ? parseSchema({ schema: schema.items as SchemaObject }, rawOptions) : ast.createSchema({ type: 'any' })
767
602
 
768
- return createSchema({
603
+ return ast.createSchema({
769
604
  type: 'tuple',
770
605
  primitive: 'array',
771
606
  items: tupleItems,
772
607
  rest,
773
608
  min: schema.minItems,
774
609
  max: schema.maxItems,
775
- ...buildSchemaBase(schema, name, nullable, defaultValue),
610
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
776
611
  })
777
612
  }
778
613
 
779
614
  /**
780
615
  * 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
616
  */
785
- function convertArray({ schema, name, nullable, defaultValue, options, mergedOptions }: SchemaContext): SchemaNode {
617
+ function convertArray({ schema, name, nullable, defaultValue, rawOptions, options }: SchemaContext): ast.SchemaNode {
786
618
  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)] : []
619
+ const itemName = rawItems?.enum?.length && name ? ast.enumPropName(undefined, name, options.enumSuffix) : undefined
620
+ const items = rawItems ? [parseSchema({ schema: rawItems, name: itemName }, rawOptions)] : []
791
621
 
792
- return createSchema({
622
+ return ast.createSchema({
793
623
  type: 'array',
794
624
  primitive: 'array',
795
625
  items,
796
626
  min: schema.minItems,
797
627
  max: schema.maxItems,
798
628
  unique: schema.uniqueItems ?? undefined,
799
- ...buildSchemaBase(schema, name, nullable, defaultValue),
629
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
800
630
  })
801
631
  }
802
632
 
803
633
  /**
804
- * Converts a `type: 'string'` schema (without a special format) into a `StringSchemaNode`.
634
+ * Converts a `type: 'string'` schema into a `StringSchemaNode`.
805
635
  */
806
- function convertString({ schema, name, nullable, defaultValue }: SchemaContext): SchemaNode {
807
- return createSchema({
636
+ function convertString({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
637
+ return ast.createSchema({
808
638
  type: 'string',
809
639
  primitive: 'string',
810
640
  min: schema.minLength,
811
641
  max: schema.maxLength,
812
642
  pattern: schema.pattern,
813
- ...buildSchemaBase(schema, name, nullable, defaultValue),
643
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
814
644
  })
815
645
  }
816
646
 
817
647
  /**
818
- * Converts a `type: 'number'` or `type: 'integer'` schema into the corresponding `SchemaNode`.
648
+ * Converts a `type: 'number'` or `type: 'integer'` schema.
819
649
  */
820
- function convertNumeric({ schema, name, nullable, defaultValue }: SchemaContext, type: 'number' | 'integer'): SchemaNode {
821
- return createSchema({
650
+ function convertNumeric({ schema, name, nullable, defaultValue }: SchemaContext, type: 'number' | 'integer'): ast.SchemaNode {
651
+ return ast.createSchema({
822
652
  type,
823
653
  primitive: type,
824
654
  min: schema.minimum,
825
655
  max: schema.maximum,
826
656
  exclusiveMinimum: typeof schema.exclusiveMinimum === 'number' ? schema.exclusiveMinimum : undefined,
827
657
  exclusiveMaximum: typeof schema.exclusiveMaximum === 'number' ? schema.exclusiveMaximum : undefined,
828
- ...buildSchemaBase(schema, name, nullable, defaultValue),
658
+ multipleOf: schema.multipleOf,
659
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
829
660
  })
830
661
  }
831
662
 
832
663
  /**
833
- * Converts a `type: 'boolean'` schema into a `BooleanSchemaNode`.
664
+ * Converts a `type: 'boolean'` schema.
834
665
  */
835
- function convertBoolean({ schema, name, nullable, defaultValue }: SchemaContext): SchemaNode {
836
- return createSchema({
666
+ function convertBoolean({ schema, name, nullable, defaultValue }: SchemaContext): ast.SchemaNode {
667
+ return ast.createSchema({
837
668
  type: 'boolean',
838
669
  primitive: 'boolean',
839
- ...buildSchemaBase(schema, name, nullable, defaultValue),
670
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
840
671
  })
841
672
  }
842
673
 
843
674
  /**
844
- * Converts an explicit `type: 'null'` or `const: null` schema into a `NullSchemaNode`.
675
+ * Converts an explicit `type: 'null'` schema.
845
676
  */
846
- function convertNull({ schema, name, nullable }: SchemaContext): SchemaNode {
847
- return createSchema({
677
+ function convertNull({ schema, name, nullable }: SchemaContext): ast.SchemaNode {
678
+ return ast.createSchema({
848
679
  type: 'null',
849
680
  primitive: 'null',
850
681
  name,
@@ -856,83 +687,70 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
856
687
  }
857
688
 
858
689
  /**
859
- * Central dispatcher: converts an OAS `SchemaObject` into a `SchemaNode`.
690
+ * Central dispatcher that converts an OAS `SchemaObject` into a `SchemaNode`.
860
691
  *
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)
692
+ * Dispatch order (first match wins): `$ref` → `allOf` → `oneOf`/`anyOf` → `const` → `format`
693
+ * octet-stream blob → multi-type array → constraint-inferred type → `enum` → object/array/tuple/scalar
694
+ * empty-schema fallback (`emptySchemaType` option).
873
695
  */
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.
696
+ function parseSchema({ schema, name }: { schema: SchemaObject; name?: string | null }, rawOptions?: Partial<ast.ParserOptions>): ast.SchemaNode {
697
+ const options: ast.ParserOptions = {
698
+ ...DEFAULT_PARSER_OPTIONS,
699
+ ...rawOptions,
700
+ }
878
701
  const flattenedSchema = flattenSchema(schema)
879
702
  if (flattenedSchema && flattenedSchema !== schema) {
880
- return convertSchema({ schema: flattenedSchema, name }, options)
703
+ return parseSchema({ schema: flattenedSchema, name }, rawOptions)
881
704
  }
882
705
 
883
706
  const nullable = isNullable(schema) || undefined
884
707
  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
708
  const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
887
709
 
888
- const ctx: SchemaContext = { schema, name, nullable, defaultValue, type, options, mergedOptions }
710
+ const ctx: SchemaContext = {
711
+ schema,
712
+ name,
713
+ nullable,
714
+ defaultValue,
715
+ type,
716
+ rawOptions,
717
+ options,
718
+ }
889
719
 
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
720
  if (isReference(schema)) return convertRef(ctx)
894
721
 
895
- // Composition keywords
896
722
  if (schema.allOf?.length) return convertAllOf(ctx)
897
723
  const unionMembers = [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]
898
724
  if (unionMembers.length) return convertUnion(ctx)
899
725
 
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
726
  if ('const' in schema && schema.const !== undefined) return convertConst(ctx)
903
727
 
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
728
  if (schema.format) {
908
729
  const formatResult = convertFormat(ctx)
909
730
  if (formatResult) return formatResult
910
731
  }
911
732
 
912
- // OAS 3.1: `contentMediaType: 'application/octet-stream'` on a string schema signals binary data.
913
733
  if (schema.type === 'string' && schema.contentMediaType === 'application/octet-stream') {
914
- return createSchema({ type: 'blob', primitive: 'string', ...buildSchemaBase(schema, name, nullable, defaultValue) })
734
+ return ast.createSchema({
735
+ type: 'blob',
736
+ primitive: 'string',
737
+ ...buildSchemaNode(schema, name, nullable, defaultValue),
738
+ })
915
739
  }
916
740
 
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
741
  if (Array.isArray(schema.type) && schema.type.length > 1) {
921
742
  const nonNullTypes = schema.type.filter((t) => t !== 'null') as string[]
922
743
  const arrayNullable = schema.type.includes('null') || nullable || undefined
923
744
 
924
745
  if (nonNullTypes.length > 1) {
925
- return createSchema({
746
+ return ast.createSchema({
926
747
  type: 'union',
927
- members: nonNullTypes.map((t) => convertSchema({ schema: { ...schema, type: t } as SchemaObject, name }, options)),
928
- ...buildSchemaBase(schema, name, arrayNullable, defaultValue),
748
+ members: nonNullTypes.map((t) => parseSchema({ schema: { ...schema, type: t } as SchemaObject, name }, rawOptions)),
749
+ ...buildSchemaNode(schema, name, arrayNullable, defaultValue),
929
750
  })
930
751
  }
931
752
  }
932
753
 
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
754
  if (!type) {
937
755
  if (schema.minLength !== undefined || schema.maxLength !== undefined || schema.pattern !== undefined) {
938
756
  return convertString(ctx)
@@ -952,74 +770,153 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
952
770
  if (type === 'boolean') return convertBoolean(ctx)
953
771
  if (type === 'null') return convertNull(ctx)
954
772
 
955
- const emptyType = resolveTypeOption(mergedOptions.emptySchemaType)
956
- return createSchema({ type: emptyType as ScalarSchemaType, name, title: schema.title, description: schema.description })
773
+ const emptyType = typeOptionMap.get(options.emptySchemaType)!
774
+ return ast.createSchema({
775
+ type: emptyType as ast.ScalarSchemaType,
776
+ name,
777
+ title: schema.title,
778
+ description: schema.description,
779
+ })
957
780
  }
958
781
 
959
782
  /**
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`.
783
+ * Converts a dereferenced OAS parameter object into a `ParameterNode`.
962
784
  */
963
- function parseParameter(options: Options, param: Record<string, unknown>): ParameterNode {
785
+ function parseParameter(options: ast.ParserOptions, param: Record<string, unknown>): ast.ParameterNode {
964
786
  const required = (param['required'] as boolean | undefined) ?? false
965
787
 
966
- const schema: SchemaNode =
967
- param['schema'] && !isReference(param['schema'])
968
- ? convertSchema({ schema: param['schema'] as SchemaObject }, options)
969
- : createSchema({ type: resolveTypeOption(options.unknownType) })
788
+ const schema: ast.SchemaNode = param['schema']
789
+ ? parseSchema({ schema: param['schema'] as SchemaObject }, options)
790
+ : ast.createSchema({ type: typeOptionMap.get(options.unknownType)! })
970
791
 
971
- return createParameter({
792
+ return ast.createParameter({
972
793
  name: param['name'] as string,
973
- in: param['in'] as ParameterLocation,
794
+ in: param['in'] as ast.ParameterLocation,
974
795
  schema: {
975
796
  ...schema,
976
797
  description: (param['description'] as string | undefined) ?? schema.description,
977
- optional: !required || !!schema.optional ? true : undefined,
978
798
  },
979
799
  required,
980
800
  })
981
801
  }
982
802
 
983
803
  /**
984
- * Converts an OAS `Operation` into an `OperationNode`, resolving parameters,
985
- * request body, and all response codes into their AST node equivalents.
804
+ * Reads the inline `requestBody` metadata (description / required) that OAS exposes
805
+ * outside the schema itself. Returns an empty object when the request body is missing or a `$ref`.
986
806
  */
987
- function parseOperation(options: Options, oas: Oas, operation: Operation): OperationNode {
988
- const parameters: Array<ParameterNode> = oas.getParameters(operation).map((param) => parseParameter(options, param as unknown as Record<string, unknown>))
807
+ function getRequestBodyMeta(operation: Operation): {
808
+ description?: string
809
+ required: boolean
810
+ } {
811
+ const body = operation.schema.requestBody as { description?: string; required?: boolean } | undefined
812
+ if (!body) return { required: false }
813
+
814
+ // After getRequestBodyContentTypes has run, body may still carry $ref but the
815
+ // resolved fields (description, required, content) are already spread onto it.
816
+ return {
817
+ description: body.description,
818
+ required: body.required === true,
819
+ }
820
+ }
989
821
 
990
- const requestBodySchema = oas.getRequestSchema(operation)
991
- const requestBody = requestBodySchema ? convertSchema({ schema: requestBodySchema }, options) : undefined
822
+ /**
823
+ * Reads the inline response object (not a `$ref`) and returns its description plus its `content` map.
824
+ */
825
+ function getResponseMeta(responseObj: unknown): {
826
+ description?: string
827
+ content?: Record<string, unknown>
828
+ } {
829
+ if (typeof responseObj !== 'object' || responseObj === null || Array.isArray(responseObj)) return {}
830
+
831
+ const inline = responseObj as {
832
+ description?: string
833
+ content?: Record<string, unknown>
834
+ }
835
+ return { description: inline.description, content: inline.content }
836
+ }
837
+
838
+ /**
839
+ * Collects property names whose schema has a truthy boolean flag (`readOnly` or `writeOnly`).
840
+ * `$ref` entries are skipped since their flags live on the dereferenced target.
841
+ */
842
+ function collectPropertyKeysByFlag(schema: SchemaObject | null, flag: 'readOnly' | 'writeOnly'): string[] | undefined {
843
+ if (!schema?.properties) return undefined
844
+
845
+ const keys: string[] = []
846
+ for (const key in schema.properties) {
847
+ const prop = schema.properties[key]
848
+ if (prop && !isReference(prop) && (prop as Record<string, unknown>)[flag]) {
849
+ keys.push(key)
850
+ }
851
+ }
852
+ return keys.length ? keys : undefined
853
+ }
854
+
855
+ /**
856
+ * Converts an OAS `Operation` into an `OperationNode`.
857
+ */
858
+ function parseOperation(options: ast.ParserOptions, operation: Operation): ast.OperationNode {
859
+ const parameters: Array<ast.ParameterNode> = getParameters(document, operation).map((param) =>
860
+ parseParameter(options, param as unknown as Record<string, unknown>),
861
+ )
862
+
863
+ // Determine which content types to include in requestBody.content.
864
+ // When a global contentType is configured, restrict to that single type.
865
+ // Otherwise include every content type declared in the spec.
866
+ const allContentTypes = ctx.contentType ? [ctx.contentType] : getRequestBodyContentTypes(document, operation)
867
+
868
+ const requestBodyMeta = getRequestBodyMeta(operation)
869
+
870
+ const content = allContentTypes.flatMap((ct) => {
871
+ const schema = getRequestSchema(document, operation, { contentType: ct })
872
+ if (!schema) return []
873
+ return [
874
+ {
875
+ contentType: ct,
876
+ schema: ast.syncOptionality(parseSchema({ schema }, options), requestBodyMeta.required),
877
+ keysToOmit: collectPropertyKeysByFlag(schema, 'readOnly'),
878
+ },
879
+ ]
880
+ })
992
881
 
993
- const responses: Array<ResponseNode> = operation.getResponseStatusCodes().map((statusCode) => {
882
+ const requestBody =
883
+ content.length > 0 || requestBodyMeta.description
884
+ ? {
885
+ description: requestBodyMeta.description,
886
+ required: requestBodyMeta.required || undefined,
887
+ content: content.length > 0 ? content : undefined,
888
+ }
889
+ : undefined
890
+
891
+ const responses: Array<ast.ResponseNode> = operation.getResponseStatusCodes().map((statusCode) => {
994
892
  const responseObj = operation.getResponseByStatusCode(statusCode)
995
- const responseSchema = oas.getResponseSchema(operation, statusCode)
893
+ const responseSchema = getResponseSchema(document, operation, statusCode, { contentType: ctx.contentType })
996
894
 
997
895
  const schema =
998
896
  responseSchema && Object.keys(responseSchema).length > 0
999
- ? convertSchema({ schema: responseSchema }, options)
1000
- : createSchema({ type: resolveTypeOption(options.emptySchemaType) })
897
+ ? parseSchema({ schema: responseSchema }, options)
898
+ : ast.createSchema({
899
+ type: typeOptionMap.get(options.emptySchemaType)!,
900
+ })
1001
901
 
1002
- const description = typeof responseObj === 'object' && responseObj !== null && !Array.isArray(responseObj) ? responseObj.description : undefined
1003
-
1004
- const rawContent =
1005
- typeof responseObj === 'object' && responseObj !== null && !Array.isArray(responseObj)
1006
- ? (responseObj as { content?: Record<string, unknown> }).content
1007
- : undefined
902
+ const { description, content } = getResponseMeta(responseObj)
903
+ const mediaType = content ? getMediaType(Object.keys(content)[0] ?? '') : getMediaType(operation.contentType ?? '')
1008
904
 
1009
- const mediaType = rawContent ? toMediaType(Object.keys(rawContent)[0] ?? '') : toMediaType(operation.contentType ?? '')
1010
-
1011
- return createResponse({
1012
- statusCode: statusCode as StatusCode,
905
+ return ast.createResponse({
906
+ statusCode: statusCode as ast.StatusCode,
1013
907
  description,
1014
908
  schema,
1015
909
  mediaType,
910
+ keysToOmit: collectPropertyKeysByFlag(responseSchema, 'writeOnly'),
1016
911
  })
1017
912
  })
1018
913
 
1019
- return createOperation({
914
+ const urlPath = new URLPath(operation.path)
915
+
916
+ return ast.createOperation({
1020
917
  operationId: operation.getOperationId(),
1021
- method: operation.method.toUpperCase() as HttpMethod,
1022
- path: new URLPath(operation.path).URL,
918
+ method: operation.method.toUpperCase() as ast.HttpMethod,
919
+ path: urlPath.path,
1023
920
  tags: operation.getTags().map((tag) => tag.name),
1024
921
  summary: operation.getSummary() || undefined,
1025
922
  description: operation.getDescription() || undefined,
@@ -1030,63 +927,76 @@ export function createOasParser(oas: Oas, { contentType, collisionDetection }: O
1030
927
  })
1031
928
  }
1032
929
 
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 }
930
+ return { parseSchema, parseOperation, parseParameter }
931
+ }
1039
932
 
1040
- const schemas: Array<SchemaNode> = Object.entries(schemaObjects).map(([name, schemaObject]) =>
1041
- convertSchema({ schema: schemaObject as SchemaObject, name }, mergedOptions),
1042
- )
933
+ /**
934
+ * Parses a single OpenAPI `SchemaObject` into a `SchemaNode`.
935
+ *
936
+ * Use this for targeted schema parsing when you don't need the full spec.
937
+ * For complete spec parsing, use `parseOas()` instead which handles operations and all schemas together.
938
+ *
939
+ * @note Circular schema references are tracked via internal state and resolve appropriately.
940
+ *
941
+ * @example
942
+ * ```ts
943
+ * const document = yaml.parse(fs.readFileSync('openapi.yaml', 'utf8'))
944
+ * const ctx = { document }
945
+ * const schema = parseSchema(ctx, { schema: { type: 'string', format: 'uuid' } })
946
+ * ```
947
+ */
948
+ export function parseSchema(
949
+ ctx: OasParserContext,
950
+ { schema, name }: { schema: SchemaObject; name?: string },
951
+ options?: Partial<ast.ParserOptions>,
952
+ ): ast.SchemaNode {
953
+ return createSchemaParser(ctx).parseSchema({ schema, name }, options)
954
+ }
955
+
956
+ /**
957
+ * Parses an OpenAPI specification into Kubb's universal `InputNode` AST.
958
+ *
959
+ * This is the main entry point for `@kubb/adapter-oas`. It converts OpenAPI/Swagger specs into a spec-agnostic tree
960
+ * that downstream plugins (`plugin-ts`, `plugin-zod`, etc.) consume for code generation. No code is generated here —
961
+ * the tree is a pure data structure representing all schemas and operations.
962
+ *
963
+ * Returns the AST root and a `nameMapping` for resolving schema references.
964
+ *
965
+ * @example
966
+ * ```ts
967
+ * import { parseOas } from '@kubb/adapter-oas'
968
+ *
969
+ * const document = await parseFromConfig(config)
970
+ * const { root, nameMapping } = parseOas(document, { dateType: 'date', contentType: 'application/json' })
971
+ * ```
972
+ */
973
+ export function parseOas(
974
+ document: Document,
975
+ options: Partial<ast.ParserOptions> & { contentType?: ContentType } = {},
976
+ ): { root: ast.InputNode; nameMapping: Map<string, string> } {
977
+ const { contentType, ...parserOptions } = options
978
+ const mergedOptions: ast.ParserOptions = {
979
+ ...DEFAULT_PARSER_OPTIONS,
980
+ ...parserOptions,
981
+ }
1043
982
 
1044
- const paths = oas.getPaths()
983
+ const { schemas: schemaObjects, nameMapping } = getSchemas(document, {
984
+ contentType,
985
+ })
986
+ const { parseSchema: _parseSchema, parseOperation: _parseOperation } = createSchemaParser({ document, contentType })
1045
987
 
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
- )
988
+ const schemas: Array<ast.SchemaNode> = Object.entries(schemaObjects).map(([name, schema]) => _parseSchema({ schema, name }, mergedOptions))
1051
989
 
1052
- return createRoot({ schemas, operations })
1053
- }
990
+ const baseOas = new BaseOas(document)
991
+ const paths = baseOas.getPaths()
1054
992
 
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
- }
993
+ const operations: Array<ast.OperationNode> = Object.entries(paths).flatMap(([_path, methods]) =>
994
+ Object.entries(methods)
995
+ .map(([, operation]) => (operation ? _parseOperation(mergedOptions, operation) : null))
996
+ .filter((op): op is ast.OperationNode => op !== null),
997
+ )
1075
998
 
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
- }
999
+ const root = ast.createInput({ schemas, operations })
1085
1000
 
1086
- return {
1087
- parse,
1088
- convertSchema,
1089
- resolveRefs,
1090
- nameMapping,
1091
- } as OasParser
1001
+ return { root, nameMapping }
1092
1002
  }