@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/dist/index.cjs +262 -137
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +262 -137
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/adapter.ts +1 -1
- package/src/constants.ts +13 -14
- package/src/dedupe.ts +237 -0
- package/src/dialect.ts +10 -6
- package/src/discriminator.ts +1 -1
- package/src/factory.ts +1 -1
- package/src/mime.ts +2 -3
- package/src/operation.ts +2 -2
- package/src/parser.ts +48 -42
- package/src/refs.ts +4 -3
- package/src/resolvers.ts +13 -5
- package/src/stream.ts +19 -81
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubb/adapter-oas",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
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.
|
|
50
|
-
"@kubb/core": "5.0.0-beta.
|
|
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
|
-
*
|
|
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
|
|
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
|
|
76
|
-
* in the parser. `ipv4` and `ipv6` map to their own dedicated schema types. `hostname`
|
|
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.
|
|
26
|
+
export const oasDialect = ast.defineDialect({
|
|
26
27
|
name: 'oas',
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
/**
|
package/src/discriminator.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type DiscriminatorTarget = {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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
|
|
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
|
|
11
|
-
*
|
|
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')
|
|
16
|
+
* resolveRef<SchemaObject>(document, '#/components/schemas/Pet')
|
|
16
17
|
* ```
|
|
17
18
|
*/
|
|
18
19
|
export function resolveRef<T = unknown>(document: Document, $ref: string): T | null {
|