@kubb/adapter-oas 5.0.0-beta.61 → 5.0.0-beta.63

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubb/adapter-oas",
3
- "version": "5.0.0-beta.61",
3
+ "version": "5.0.0-beta.63",
4
4
  "description": "OpenAPI and Swagger adapter for Kubb. Parses and validates OAS 2.0/3.x specifications into a @kubb/ast RootNode for downstream code generation plugins.",
5
5
  "keywords": [
6
6
  "adapter",
@@ -46,8 +46,8 @@
46
46
  "api-ref-bundler": "0.5.1",
47
47
  "swagger2openapi": "^7.0.8",
48
48
  "yaml": "^2.9.0",
49
- "@kubb/ast": "5.0.0-beta.61",
50
- "@kubb/core": "5.0.0-beta.61"
49
+ "@kubb/ast": "5.0.0-beta.63",
50
+ "@kubb/core": "5.0.0-beta.63"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/json-schema": "^7.0.15",
package/src/adapter.ts CHANGED
@@ -10,7 +10,7 @@ import { collect, narrowSchema } from '@kubb/ast'
10
10
  import { extractRefName } from '@kubb/ast/utils'
11
11
 
12
12
  /**
13
- * Canonical adapter name for `@kubb/adapter-oas`. Used for driver lookups.
13
+ * The `name` of `@kubb/adapter-oas`, used to identify this adapter in a Kubb config.
14
14
  */
15
15
  export const adapterOasName = 'oas' satisfies AdapterOas['name']
16
16
 
package/src/constants.ts CHANGED
@@ -5,10 +5,9 @@ import { ast } from '@kubb/core'
5
5
  *
6
6
  * @example
7
7
  * ```ts
8
- * import { DEFAULT_PARSER_OPTIONS } from '@kubb/adapter-oas'
8
+ * import { DEFAULT_PARSER_OPTIONS, parseOas } from '@kubb/adapter-oas'
9
9
  *
10
- * const parser = createOasParser(oas)
11
- * const root = parser.parse({ ...DEFAULT_PARSER_OPTIONS, dateType: 'date' })
10
+ * const { root } = parseOas(document, { ...DEFAULT_PARSER_OPTIONS, dateType: 'date' })
12
11
  * ```
13
12
  */
14
13
  export const DEFAULT_PARSER_OPTIONS = {
@@ -68,13 +67,21 @@ export const MERGE_DEFAULT_VERSION = '1.0.0' as const
68
67
  */
69
68
  export const structuralKeys = new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf', 'not'] as const)
70
69
 
70
+ /**
71
+ * Formats `convertFormat` maps to a dedicated type without going through `formatMap`:
72
+ * `int64` and the date/time family. Keep this in sync with the `convertFormat`
73
+ * special-cases in `parser.ts`. `isHandledFormat` reads it so the
74
+ * `KUBB_UNSUPPORTED_FORMAT` diagnostic and the parser agree on what is handled.
75
+ */
76
+ export const specialCasedFormats: ReadonlySet<string> = new Set(['int64', 'date-time', 'date', 'time'])
77
+
71
78
  /**
72
79
  * Static map from OAS `format` strings to Kubb `SchemaType` values.
73
80
  *
74
81
  * Only formats whose AST type differs from the OAS `type` field appear here.
75
- * Formats that depend on runtime options (`int64`, `date-time`, `date`, `time`) are handled separately
76
- * in the parser. `ipv4` and `ipv6` map to their own dedicated schema types. `hostname` and
77
- * `idn-hostname` map to `'url'` as the closest generic string-format type.
82
+ * Formats that depend on runtime options (`int64`, `date-time`, `date`, `time`) are handled
83
+ * separately in the parser. `ipv4` and `ipv6` map to their own dedicated schema types. `hostname`
84
+ * and `idn-hostname` map to `'url'` as the closest generic string-format type.
78
85
  *
79
86
  * @example
80
87
  * ```ts
@@ -85,14 +92,6 @@ export const structuralKeys = new Set(['properties', 'items', 'additionalPropert
85
92
  * formatMap['float'] // 'number'
86
93
  * ```
87
94
  */
88
- /**
89
- * Formats `convertFormat` maps to a dedicated type without going through `formatMap`:
90
- * `int64` and the date/time family. Keep this in sync with the `convertFormat`
91
- * special-cases in `parser.ts`. `isHandledFormat` reads it so the
92
- * `KUBB_UNSUPPORTED_FORMAT` diagnostic and the parser agree on what is handled.
93
- */
94
- export const specialCasedFormats: ReadonlySet<string> = new Set(['int64', 'date-time', 'date', 'time'])
95
-
96
95
  export const formatMap = {
97
96
  uuid: 'uuid',
98
97
  email: 'email',
package/src/dedupe.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { containsCircularRef, extractRefName } from '@kubb/ast/utils'
2
+ import { ast } from '@kubb/core'
3
+ import { SCHEMA_REF_PREFIX } from './constants.ts'
4
+
5
+ /**
6
+ * The destination a deduplicated shape points at: the shared schema name and the
7
+ * synthetic `$ref` path stored on the generated `ref` nodes.
8
+ */
9
+ type Target = {
10
+ name: string
11
+ ref: string
12
+ }
13
+
14
+ /**
15
+ * The result of {@link plan}: the shared definitions to prepend to the schema list, plus the
16
+ * rewriting behavior that repoints duplicate shapes at their shared target. The lookup maps stay
17
+ * private to the closure, so callers interact with one object instead of three collections.
18
+ */
19
+ export type Plan = {
20
+ /**
21
+ * New top-level schema definitions created for inline shapes that had no existing named
22
+ * component. Nested duplicates inside each definition are already collapsed.
23
+ */
24
+ extracted: Array<ast.SchemaNode>
25
+ /**
26
+ * Rewrites an operation or nested schema, replacing every duplicate sub-schema with a `ref` to
27
+ * its shared target. Replacing a node prunes its subtree, so nested duplicates inside a replaced
28
+ * shape are not visited again. A `ref` to a duplicate top-level schema is repointed at the first
29
+ * schema with the same content.
30
+ */
31
+ apply<T extends ast.Node>(node: T): T
32
+ /**
33
+ * Rewrites a top-level schema. A schema whose content duplicates a different shared one becomes a
34
+ * `ref` alias to it (keeping its own name and docs). Otherwise its nested duplicates collapse
35
+ * while its own root is preserved.
36
+ */
37
+ applyTopLevel(node: ast.SchemaNode): ast.SchemaNode
38
+ /**
39
+ * Whether a top-level name duplicates an earlier schema with the same content. Such a schema is
40
+ * never emitted, since every reference to it is repointed at the first schema with that content.
41
+ */
42
+ isAlias(name: string): boolean
43
+ }
44
+
45
+ /**
46
+ * Runtime state {@link plan} reads from the current parse: schema names that take part in a
47
+ * circular chain (never extracted, extracting them would break the cycle) and the names already in
48
+ * use (so extracted definitions resolve collisions).
49
+ */
50
+ export type DedupeContext = {
51
+ circularSchemas: ReadonlySet<string>
52
+ usedNames: Set<string>
53
+ }
54
+
55
+ /**
56
+ * Minimum occurrences before a shape is deduplicated.
57
+ */
58
+ const MIN_OCCURRENCES = 2
59
+
60
+ /**
61
+ * Builds the shared `ref` replacement for a duplicate occurrence, carrying the
62
+ * usage-slot and documentation fields that are not part of the shared type.
63
+ */
64
+ function createRefNode(node: ast.SchemaNode, target: Target): ast.SchemaNode {
65
+ return ast.factory.createSchema({
66
+ type: 'ref',
67
+ name: target.name,
68
+ ref: target.ref,
69
+ optional: node.optional,
70
+ nullish: node.nullish,
71
+ readOnly: node.readOnly,
72
+ writeOnly: node.writeOnly,
73
+ deprecated: node.deprecated,
74
+ description: node.description,
75
+ default: node.default,
76
+ example: node.example,
77
+ })
78
+ }
79
+
80
+ /**
81
+ * Strips usage-slot flags from an extracted definition and applies its name.
82
+ * A standalone definition is never optional, so `optional`/`nullish` are cleared.
83
+ */
84
+ function cleanDefinition(node: ast.SchemaNode, name: string): ast.SchemaNode {
85
+ return { ...node, name, optional: undefined, nullish: undefined }
86
+ }
87
+
88
+ /**
89
+ * Returns `true` when a node is eligible for deduplication. Only enums and objects qualify, and
90
+ * object shapes that take part in a circular chain are rejected so recursive structures are not
91
+ * extracted (which would break the cycle).
92
+ */
93
+ function isCandidate(node: ast.SchemaNode, circularSchemas: ReadonlySet<string>): boolean {
94
+ if (node.type === 'enum') return true
95
+ if (node.type !== 'object') return false
96
+ if (node.name && circularSchemas.has(node.name)) return false
97
+ return !containsCircularRef(node, { circularSchemas })
98
+ }
99
+
100
+ /**
101
+ * Produces the name for an inline shape with no existing named component, resolving
102
+ * collisions against `usedNames`. Returns `null` for an unnamed shape, which stays inline.
103
+ */
104
+ function nameFor(node: ast.SchemaNode, usedNames: Set<string>): string | null {
105
+ const base = node.name
106
+ if (!base) return null
107
+
108
+ let name = base
109
+ let counter = 2
110
+ while (usedNames.has(name)) {
111
+ name = `${base}${counter++}`
112
+ }
113
+ usedNames.add(name)
114
+ return name
115
+ }
116
+
117
+ /**
118
+ * Scans a forest of schema and operation nodes and produces a {@link Plan}.
119
+ *
120
+ * A shape that occurs at least {@link MIN_OCCURRENCES} times is deduplicated: if any occurrence is
121
+ * a named top-level schema, the first one becomes the target (so other top-level duplicates and
122
+ * inline copies turn into references to it). Other top-level names with the same content are
123
+ * recorded as aliases. Otherwise a new definition is extracted using {@link nameFor}. The returned
124
+ * plan rewrites nodes against those decisions.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * const dedupePlan = plan([...schemaNodes, ...operationNodes], { circularSchemas, usedNames })
129
+ * ```
130
+ */
131
+ export function plan(roots: ReadonlyArray<ast.Node>, context: DedupeContext): Plan {
132
+ const { circularSchemas, usedNames } = context
133
+
134
+ const topLevelNodes = new Set<ast.SchemaNode>()
135
+
136
+ type Group = {
137
+ count: number
138
+ representative: ast.SchemaNode
139
+ topLevelNames: Array<string>
140
+ }
141
+ const groups = new Map<string, Group>()
142
+
143
+ for (const root of roots) {
144
+ if (root.kind === 'Schema') topLevelNodes.add(root)
145
+ for (const schemaNode of ast.collect<ast.SchemaNode>(root, { schema: (node) => node })) {
146
+ if (!isCandidate(schemaNode, circularSchemas)) continue
147
+
148
+ const signature = ast.signatureOf(schemaNode)
149
+ const isTopLevel = topLevelNodes.has(schemaNode) && !!schemaNode.name
150
+ const group = groups.get(signature)
151
+ if (group) {
152
+ group.count++
153
+ if (isTopLevel) group.topLevelNames.push(schemaNode.name!)
154
+ } else {
155
+ groups.set(signature, { count: 1, representative: schemaNode, topLevelNames: isTopLevel ? [schemaNode.name!] : [] })
156
+ }
157
+ }
158
+ }
159
+
160
+ const bySignature = new Map<string, Target>()
161
+ const byName = new Map<string, Target>()
162
+ const pendingExtractions: Array<{ name: string; representative: ast.SchemaNode }> = []
163
+
164
+ for (const [signature, group] of groups) {
165
+ if (group.count < MIN_OCCURRENCES) continue
166
+
167
+ const [firstName, ...duplicateNames] = group.topLevelNames
168
+ if (firstName) {
169
+ const target: Target = { name: firstName, ref: `${SCHEMA_REF_PREFIX}${firstName}` }
170
+ bySignature.set(signature, target)
171
+ for (const duplicate of duplicateNames) {
172
+ byName.set(duplicate, target)
173
+ }
174
+ continue
175
+ }
176
+
177
+ const name = nameFor(group.representative, usedNames)
178
+ if (!name) continue
179
+
180
+ bySignature.set(signature, { name, ref: `${SCHEMA_REF_PREFIX}${name}` })
181
+ pendingExtractions.push({ name, representative: group.representative })
182
+ }
183
+
184
+ // Rewrites a node against the resolved targets. `skipRootMatch` keeps a definition's own root from
185
+ // turning into a reference to itself. Nested duplicates are still collapsed.
186
+ function rewrite<T extends ast.Node>(node: T, skipRootMatch: boolean): T {
187
+ if (bySignature.size === 0 && byName.size === 0) return node
188
+
189
+ const root = node
190
+
191
+ return ast.transform(node, {
192
+ schema(schemaNode) {
193
+ if (schemaNode.type === 'ref') {
194
+ const refName = schemaNode.ref ? extractRefName(schemaNode.ref) : schemaNode.name
195
+ const target = refName ? byName.get(refName) : undefined
196
+
197
+ return target ? { ...schemaNode, name: target.name, ref: target.ref } : undefined
198
+ }
199
+
200
+ if (skipRootMatch && schemaNode === root) return undefined
201
+
202
+ const target = bySignature.get(ast.signatureOf(schemaNode))
203
+ if (!target) return undefined
204
+
205
+ return createRefNode(schemaNode, target)
206
+ },
207
+ }) as T
208
+ }
209
+
210
+ // Build extracted definitions only after every target name is known, so nested
211
+ // duplicates inside a definition also resolve to refs.
212
+ const extracted = pendingExtractions.map(({ name, representative }) => cleanDefinition(rewrite(representative, true), name))
213
+
214
+ return {
215
+ extracted,
216
+ apply<T extends ast.Node>(node: T): T {
217
+ return rewrite(node, false)
218
+ },
219
+ applyTopLevel(node: ast.SchemaNode): ast.SchemaNode {
220
+ const target = bySignature.get(ast.signatureOf(node))
221
+ if (target && target.name !== node.name) {
222
+ return ast.factory.createSchema({
223
+ type: 'ref',
224
+ name: node.name ?? null,
225
+ ref: target.ref,
226
+ description: node.description,
227
+ deprecated: node.deprecated,
228
+ })
229
+ }
230
+
231
+ return rewrite(node, true)
232
+ },
233
+ isAlias(name: string): boolean {
234
+ return byName.has(name)
235
+ },
236
+ }
237
+ }
package/src/dialect.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ast } from '@kubb/core'
2
+ import { plan } from './dedupe.ts'
2
3
  import { isDiscriminator, isNullable, isReference } from './guards.ts'
3
4
  import { resolveRef } from './refs.ts'
4
5
  import type { SchemaObject } from './types.ts'
@@ -22,13 +23,16 @@ import type { SchemaObject } from './types.ts'
22
23
  * const parser = createSchemaParser(context, oasDialect) // explicit
23
24
  * ```
24
25
  */
25
- export const oasDialect = ast.defineSchemaDialect({
26
+ export const oasDialect = ast.defineDialect({
26
27
  name: 'oas',
27
- isNullable,
28
- isReference,
29
- isDiscriminator,
30
- isBinary: (schema: SchemaObject) => schema.type === 'string' && schema.contentMediaType === 'application/octet-stream',
31
- resolveRef,
28
+ schema: {
29
+ isNullable,
30
+ isReference,
31
+ isDiscriminator,
32
+ isBinary: (schema: SchemaObject) => schema.type === 'string' && schema.contentMediaType === 'application/octet-stream',
33
+ resolveRef,
34
+ },
35
+ dedupe: { plan },
32
36
  })
33
37
 
34
38
  /**
@@ -7,7 +7,7 @@ export type DiscriminatorTarget = {
7
7
  }
8
8
 
9
9
  /**
10
- * Builds a map of child schema names discriminator patch data by scanning the given
10
+ * Maps each child schema name to its discriminator patch data by scanning the given
11
11
  * top-level AST schema nodes for union schemas that carry a `discriminatorPropertyName`.
12
12
  *
13
13
  * The streaming path calls this on a small pre-parsed subset of schemas (only the
package/src/factory.ts CHANGED
@@ -18,7 +18,7 @@ export type ValidateDocumentOptions = {
18
18
  }
19
19
 
20
20
  /**
21
- * Loads and dereferences an OpenAPI document, returning the raw `Document`.
21
+ * Loads and bundles an OpenAPI document, returning the raw `Document`.
22
22
  *
23
23
  * Accepts a file path string or an already-parsed document object. File paths and URLs are
24
24
  * bundled via `api-ref-bundler`, hoisting external file schemas into named `components.schemas`
package/src/mime.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * MIME type fragments that mark a media type as JSON-like.
3
3
  *
4
- * Matches the substrings used by the `oas` library's `matchesMimeType.json`, so a content type is
5
- * JSON when it contains any of these. The `+json` entry catches structured-syntax suffixes such as
6
- * `application/vnd.api+json`.
4
+ * A content type is JSON when it contains any of these substrings. The `+json` entry catches
5
+ * structured-syntax suffixes such as `application/vnd.api+json`.
7
6
  */
8
7
  const jsonMimeFragments = ['application/json', 'application/x-json', 'text/json', 'text/x-json', '+json'] as const
9
8
 
package/src/operation.ts CHANGED
@@ -125,8 +125,8 @@ export function getRequestContent({
125
125
  }
126
126
 
127
127
  /**
128
- * Returns the primary request content type. Prefers a JSON-like media type (the last one wins,
129
- * matching the previous behavior), then the first declared one, defaulting to `'application/json'`.
128
+ * Returns the primary request content type. Prefers a JSON-like media type (the last one wins
129
+ * when several are declared), then the first declared one, defaulting to `'application/json'`.
130
130
  */
131
131
  export function getRequestContentType({ document, operation }: OperationContext): string {
132
132
  const content = getRequestBodyContent({ document, operation })
package/src/parser.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  getSchemaType,
21
21
  } from './resolvers.ts'
22
22
  import type { ContentType, Document, Operation, ReferenceObject, SchemaObject } from './types.ts'
23
+ import type { StatusCode } from '@kubb/ast'
23
24
 
24
25
  /**
25
26
  * Parser context holding the raw OpenAPI document and optional content-type override.
@@ -101,6 +102,22 @@ function normalizeArrayEnum(schema: SchemaObject): SchemaObject {
101
102
  return { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject
102
103
  }
103
104
 
105
+ /**
106
+ * Builds a `null` scalar node carrying the schema's documentation. Shared by the `const: null`
107
+ * and the drf-spectacular `NullEnum` (`{ enum: [null] }`) branches, which render identically.
108
+ */
109
+ function createNullSchema(schema: SchemaObject, name: string | null | undefined): ast.SchemaNode {
110
+ return ast.factory.createSchema({
111
+ type: 'null',
112
+ primitive: 'null',
113
+ name,
114
+ title: schema.title,
115
+ description: schema.description,
116
+ deprecated: schema.deprecated,
117
+ format: schema.format,
118
+ })
119
+ }
120
+
104
121
  /**
105
122
  * Names the inline enums on a property's schema, and on each item when the property is a tuple, from
106
123
  * the parent and property name. Wraps `macroEnumName` at the property construction site.
@@ -162,7 +179,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
162
179
  if (refPath && !resolvingRefs.has(refPath)) {
163
180
  if (!resolvedRefCache.has(refPath)) {
164
181
  try {
165
- const referenced = dialect.resolveRef<SchemaObject>(document, refPath)
182
+ const referenced = dialect.schema.resolveRef<SchemaObject>(document, refPath)
166
183
  if (referenced) {
167
184
  resolvingRefs.add(refPath)
168
185
  resolvedSchema = parseSchema({ schema: referenced }, rawOptions)
@@ -223,13 +240,13 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
223
240
  }> = []
224
241
  const allOfMembers: Array<ast.SchemaNode> = (schema.allOf as Array<SchemaObject | ReferenceObject>)
225
242
  .filter((item) => {
226
- if (!dialect.isReference(item) || !name) return true
227
- const deref = dialect.resolveRef<SchemaObject>(document, item.$ref)
228
- if (!deref || !dialect.isDiscriminator(deref)) return true
243
+ if (!dialect.schema.isReference(item) || !name) return true
244
+ const deref = dialect.schema.resolveRef<SchemaObject>(document, item.$ref)
245
+ if (!deref || !dialect.schema.isDiscriminator(deref)) return true
229
246
  const parentUnion = deref.oneOf ?? deref.anyOf
230
247
  if (!parentUnion) return true
231
248
  const childRef = `${SCHEMA_REF_PREFIX}${name}`
232
- const inOneOf = parentUnion.some((oneOfItem) => dialect.isReference(oneOfItem) && oneOfItem.$ref === childRef)
249
+ const inOneOf = parentUnion.some((oneOfItem) => dialect.schema.isReference(oneOfItem) && oneOfItem.$ref === childRef)
233
250
  const inMapping = Object.values(deref.discriminator.mapping ?? {}).some((v) => v === childRef)
234
251
  if (inOneOf || inMapping) {
235
252
  const discriminatorValue = findDiscriminator(deref.discriminator.mapping, childRef)
@@ -253,9 +270,9 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
253
270
 
254
271
  if (missingRequired.length) {
255
272
  const resolvedMembers = (schema.allOf as Array<SchemaObject | ReferenceObject>).flatMap((item) => {
256
- if (!dialect.isReference(item)) return [item as SchemaObject]
257
- const deref = dialect.resolveRef<SchemaObject>(document, item.$ref)
258
- return deref && !dialect.isReference(deref) ? [deref] : []
273
+ if (!dialect.schema.isReference(item)) return [item as SchemaObject]
274
+ const deref = dialect.schema.resolveRef<SchemaObject>(document, item.$ref)
275
+ return deref && !dialect.schema.isReference(deref) ? [deref] : []
259
276
  })
260
277
 
261
278
  for (const key of missingRequired) {
@@ -322,10 +339,10 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
322
339
  const strategy: 'one' | 'any' = schema.oneOf ? 'one' : 'any'
323
340
  const unionBase = {
324
341
  ...buildSchemaNode(schema, name, nullable, defaultValue),
325
- discriminatorPropertyName: dialect.isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
342
+ discriminatorPropertyName: dialect.schema.isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
326
343
  strategy,
327
344
  }
328
- const discriminator = dialect.isDiscriminator(schema) ? schema.discriminator : undefined
345
+ const discriminator = dialect.schema.isDiscriminator(schema) ? schema.discriminator : undefined
329
346
  const sharedPropertiesNode = schema.properties
330
347
  ? (() => {
331
348
  const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
@@ -338,7 +355,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
338
355
 
339
356
  if (sharedPropertiesNode || discriminator?.mapping) {
340
357
  const members = unionMembers.map((s) => {
341
- const ref = dialect.isReference(s) ? s.$ref : undefined
358
+ const ref = dialect.schema.isReference(s) ? s.$ref : undefined
342
359
  const discriminatorValue = findDiscriminator(discriminator?.mapping, ref)
343
360
  const memberNode = parseSchema({ schema: s as SchemaObject, name }, rawOptions)
344
361
 
@@ -401,15 +418,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
401
418
  const constValue = schema.const
402
419
 
403
420
  if (constValue === null) {
404
- return ast.factory.createSchema({
405
- type: 'null',
406
- primitive: 'null',
407
- name,
408
- title: schema.title,
409
- description: schema.description,
410
- deprecated: schema.deprecated,
411
- format: schema.format,
412
- })
421
+ return createNullSchema(schema, name)
413
422
  }
414
423
 
415
424
  const constPrimitive = getPrimitiveType(typeof constValue === 'number' ? 'number' : typeof constValue === 'boolean' ? 'boolean' : 'string')
@@ -528,15 +537,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
528
537
  // render as `never` (plugin-ts) / invalid `z.enum([])` (plugin-zod). Mirror the `const: null`
529
538
  // branch so it renders as a clean `null` (not `z.null().nullable()`).
530
539
  if (nullInEnum && filteredValues.length === 0) {
531
- return ast.factory.createSchema({
532
- type: 'null',
533
- primitive: 'null',
534
- name,
535
- title: schema.title,
536
- description: schema.description,
537
- deprecated: schema.deprecated,
538
- format: schema.format,
539
- })
540
+ return createNullSchema(schema, name)
540
541
  }
541
542
 
542
543
  const enumNullable = nullable || nullInEnum || undefined
@@ -599,7 +600,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
599
600
  ? Object.entries(schema.properties).map(([propName, propSchema]) => {
600
601
  const required = Array.isArray(schema.required) ? schema.required.includes(propName) : !!schema.required
601
602
  const resolvedPropSchema = propSchema as SchemaObject
602
- const propNullable = dialect.isNullable(resolvedPropSchema)
603
+ const propNullable = dialect.schema.isNullable(resolvedPropSchema)
603
604
 
604
605
  const resolvedChildName = childName(name, propName)
605
606
  const propNode = parseSchema({ schema: resolvedPropSchema, name: resolvedChildName }, rawOptions)
@@ -653,7 +654,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
653
654
  ...buildSchemaNode(schema, name, nullable, defaultValue),
654
655
  })
655
656
 
656
- if (dialect.isDiscriminator(schema) && schema.discriminator.mapping) {
657
+ if (dialect.schema.isDiscriminator(schema) && schema.discriminator.mapping) {
657
658
  const discPropName = schema.discriminator.propertyName
658
659
  const values = Object.keys(schema.discriminator.mapping)
659
660
  const enumName = name ? enumPropName(name, discPropName, options.enumSuffix) : undefined
@@ -795,14 +796,14 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
795
796
  * match/convert/fall-through contract.
796
797
  */
797
798
  const schemaRules: Array<SchemaRule> = [
798
- { name: 'ref', match: ({ schema }) => dialect.isReference(schema), convert: convertRef },
799
+ { name: 'ref', match: ({ schema }) => dialect.schema.isReference(schema), convert: convertRef },
799
800
  { name: 'allOf', match: ({ schema }) => !!schema.allOf?.length, convert: convertAllOf },
800
801
  { name: 'union', match: ({ schema }) => !!(schema.oneOf?.length || schema.anyOf?.length), convert: convertUnion },
801
802
  { name: 'const', match: ({ schema }) => 'const' in schema && schema.const !== undefined, convert: convertConst },
802
803
  { name: 'format', match: ({ schema }) => !!schema.format, convert: convertFormat },
803
804
  {
804
805
  name: 'blob',
805
- match: ({ schema }) => dialect.isBinary(schema),
806
+ match: ({ schema }) => dialect.schema.isBinary(schema),
806
807
  convert: convertBlob,
807
808
  },
808
809
  { name: 'multi-type', match: ({ schema }) => Array.isArray(schema.type) && schema.type.length > 1, convert: convertMultiType },
@@ -848,7 +849,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
848
849
  return parseSchema({ schema: flattenedSchema, name }, rawOptions)
849
850
  }
850
851
 
851
- const nullable = dialect.isNullable(schema) || undefined
852
+ const nullable = dialect.schema.isNullable(schema) || undefined
852
853
  const defaultValue = schema.default === null && nullable ? undefined : schema.default
853
854
  const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
854
855
 
@@ -946,7 +947,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
946
947
  const keys: Array<string> = []
947
948
  for (const key in schema.properties) {
948
949
  const prop = schema.properties[key]
949
- if (prop && !dialect.isReference(prop) && (prop as Record<string, unknown>)[flag]) {
950
+ if (prop && !dialect.schema.isReference(prop) && (prop as Record<string, unknown>)[flag]) {
950
951
  keys.push(key)
951
952
  }
952
953
  }
@@ -975,11 +976,11 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
975
976
  const schema = getRequestSchema(document, operation, { contentType: ct })
976
977
  if (!schema) return []
977
978
  return [
978
- {
979
+ ast.factory.createContent({
979
980
  contentType: ct,
980
- schema: ast.syncOptionality(parseSchema({ schema, name: requestBodyName }, options), requestBodyMeta.required),
981
+ schema: ast.optionality(parseSchema({ schema, name: requestBodyName }, options), requestBodyMeta.required),
981
982
  keysToOmit: collectPropertyKeysByFlag(schema, 'readOnly'),
982
- },
983
+ }),
983
984
  ]
984
985
  })
985
986
 
@@ -1013,23 +1014,28 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
1013
1014
  // Build one entry per declared response content type so plugins can union the variants.
1014
1015
  // When a global contentType is configured, restrict to that single type (mirrors requestBody).
1015
1016
  const responseContentTypes = ctx.contentType ? [ctx.contentType] : getResponseBodyContentTypes(document, operation, statusCode)
1016
- const content = responseContentTypes.map((contentType) => ({ contentType, ...parseEntrySchema(contentType) }))
1017
+ const content = responseContentTypes.map((contentType) => ast.factory.createContent({ contentType, ...parseEntrySchema(contentType) }))
1017
1018
 
1018
1019
  // Body-less responses keep a single fallback entry so the response still resolves to a
1019
1020
  // (void/any) schema, matching how `requestBody` only carries schemas inside `content`.
1020
1021
  if (content.length === 0) {
1021
- content.push({ contentType: getRequestContentType({ document, operation }) || 'application/json', ...parseEntrySchema(ctx.contentType) })
1022
+ content.push(
1023
+ ast.factory.createContent({
1024
+ contentType: getRequestContentType({ document, operation }) || 'application/json',
1025
+ ...parseEntrySchema(ctx.contentType),
1026
+ }),
1027
+ )
1022
1028
  }
1023
1029
 
1024
1030
  return ast.factory.createResponse({
1025
- statusCode: statusCode as ast.StatusCode,
1031
+ statusCode: statusCode as StatusCode,
1026
1032
  description,
1027
1033
  content,
1028
1034
  })
1029
1035
  })
1030
1036
 
1031
1037
  const pathItem = document.paths?.[operation.path]
1032
- const pathItemDoc = pathItem && !dialect.isReference(pathItem) ? (pathItem as { summary?: unknown; description?: unknown }) : undefined
1038
+ const pathItemDoc = pathItem && !dialect.schema.isReference(pathItem) ? (pathItem as { summary?: unknown; description?: unknown }) : undefined
1033
1039
  const pickDoc = (key: 'summary' | 'description'): string | undefined => {
1034
1040
  const own = operation.schema[key]
1035
1041
  if (typeof own === 'string') return own
package/src/refs.ts CHANGED
@@ -7,12 +7,13 @@ const _refCache = new WeakMap<Document, Map<string, unknown>>()
7
7
  /**
8
8
  * Resolves a local JSON pointer reference from a document.
9
9
  *
10
- * Accepts `#/...` refs. Returns `null` for empty or non-local refs.
11
- * Throws when the pointer cannot be resolved.
10
+ * Accepts `#/...` refs. Returns `null` for an empty or non-local ref. When the pointer cannot be
11
+ * resolved, reports a `refNotFound` diagnostic into the active build and returns `null`. Outside a
12
+ * build there is no sink to collect it, so it throws instead.
12
13
  *
13
14
  * @example
14
15
  * ```ts
15
- * resolveRef<SchemaObject>(document, '#/components/schemas/Pet') // SchemaObject | null
16
+ * resolveRef<SchemaObject>(document, '#/components/schemas/Pet')
16
17
  * ```
17
18
  */
18
19
  export function resolveRef<T = unknown>(document: Document, $ref: string): T | null {