@kubb/adapter-oas 5.0.0-beta.4 → 5.0.0-beta.40
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/README.md +100 -0
- package/dist/index.cjs +900 -311
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +52 -69
- package/dist/index.js +902 -310
- package/dist/index.js.map +1 -1
- package/extension.yaml +144 -57
- package/package.json +6 -4
- package/src/adapter.bench.ts +64 -0
- package/src/adapter.ts +143 -48
- package/src/constants.ts +8 -0
- package/src/dialect.ts +38 -0
- package/src/discriminator.ts +31 -47
- package/src/factory.ts +38 -12
- package/src/index.ts +2 -2
- package/src/parser.ts +233 -137
- package/src/refs.ts +31 -5
- package/src/resolvers.ts +77 -38
- package/src/schemaDiagnostics.ts +76 -0
- package/src/stream.ts +278 -0
- package/src/types.ts +34 -11
- /package/dist/{chunk--u3MIqq1.js → chunk-C0LytTxp.js} +0 -0
package/src/refs.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { type Diagnostic, Diagnostics } from '@kubb/core'
|
|
1
2
|
import { isReference } from './guards.ts'
|
|
2
3
|
import type { Document } from './types.ts'
|
|
3
4
|
|
|
5
|
+
const _refCache = new WeakMap<Document, Map<string, unknown>>()
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Resolves a local JSON pointer reference from a document.
|
|
6
9
|
*
|
|
@@ -18,19 +21,42 @@ export function resolveRef<T = unknown>(document: Document, $ref: string): T | n
|
|
|
18
21
|
if ($ref === '') {
|
|
19
22
|
return null
|
|
20
23
|
}
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
if (!$ref.startsWith('#')) return null
|
|
25
|
+
$ref = globalThis.decodeURIComponent($ref.substring(1))
|
|
26
|
+
|
|
27
|
+
let docCache = _refCache.get(document)
|
|
28
|
+
if (!docCache) {
|
|
29
|
+
docCache = new Map()
|
|
30
|
+
_refCache.set(document, docCache)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (docCache.has($ref)) {
|
|
34
|
+
return docCache.get($ref) as T
|
|
25
35
|
}
|
|
36
|
+
|
|
26
37
|
const current = $ref
|
|
27
38
|
.split('/')
|
|
28
39
|
.filter(Boolean)
|
|
29
40
|
.reduce((obj: unknown, key: string) => (obj as Record<string, unknown>)?.[key], document as unknown)
|
|
30
41
|
|
|
31
42
|
if (!current) {
|
|
32
|
-
|
|
43
|
+
const diagnostic: Diagnostic = {
|
|
44
|
+
code: Diagnostics.code.refNotFound,
|
|
45
|
+
severity: 'error',
|
|
46
|
+
message: `Could not find a definition for ${origRef}.`,
|
|
47
|
+
help: 'Add the schema under `components.schemas`, or fix the `$ref`. Run `kubb validate` to check the spec.',
|
|
48
|
+
location: { kind: 'schema', pointer: origRef, ref: origRef },
|
|
49
|
+
}
|
|
50
|
+
// Report the unresolved ref into the active build and resolve to null, like any
|
|
51
|
+
// other unresolvable ref. The build collects it and keeps going. Outside a build there is no
|
|
52
|
+
// sink, so throw rather than silently returning null.
|
|
53
|
+
if (!Diagnostics.report(diagnostic)) {
|
|
54
|
+
throw new Diagnostics.Error(diagnostic)
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
33
57
|
}
|
|
58
|
+
|
|
59
|
+
docCache.set($ref, current)
|
|
34
60
|
return current as T
|
|
35
61
|
}
|
|
36
62
|
|
package/src/resolvers.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { pascalCase } from '@internals/utils'
|
|
2
|
-
import {
|
|
2
|
+
import { Diagnostics } from '@kubb/core'
|
|
3
|
+
import type { ast } from '@kubb/core'
|
|
3
4
|
import type { ParameterObject, ServerObject } from 'oas/types'
|
|
4
5
|
import { isRef } from 'oas/types'
|
|
5
6
|
import { matchesMimeType } from 'oas/utils'
|
|
6
|
-
import { formatMap, SCHEMA_REF_PREFIX, structuralKeys } from './constants.ts'
|
|
7
|
+
import { formatMap, SCHEMA_REF_PREFIX, specialCasedFormats, structuralKeys } from './constants.ts'
|
|
7
8
|
import { isReference } from './guards.ts'
|
|
8
9
|
import { dereferenceWithRef, resolveRef } from './refs.ts'
|
|
9
10
|
import type { ContentType, Document, MediaTypeObject, Operation, ResponseObject, SchemaObject } from './types.ts'
|
|
@@ -35,7 +36,13 @@ export function resolveServerUrl(server: ServerObject, overrides?: Record<string
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
if (variable.enum?.length && !variable.enum.some((e) => String(e) === value)) {
|
|
38
|
-
throw new Error(
|
|
39
|
+
throw new Diagnostics.Error({
|
|
40
|
+
code: Diagnostics.code.invalidServerVariable,
|
|
41
|
+
severity: 'error',
|
|
42
|
+
message: `Invalid server variable value '${value}' for '${key}' when resolving ${server.url}. Valid values are: ${variable.enum.join(', ')}.`,
|
|
43
|
+
help: `Use one of the allowed enum values, or drop the enum on the '${key}' server variable.`,
|
|
44
|
+
location: { kind: 'document', pointer: '#/servers' },
|
|
45
|
+
})
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
url = url.replaceAll(`{${key}}`, value)
|
|
@@ -52,6 +59,16 @@ export function getSchemaType(format: string): ast.SchemaType | null {
|
|
|
52
59
|
return formatMap[format as keyof typeof formatMap] ?? null
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Whether the parser maps `format` to a dedicated type. True for any `formatMap` entry and the
|
|
64
|
+
* `specialCasedFormats` `convertFormat` handles directly. False means the format falls back to the
|
|
65
|
+
* base type, which is what `KUBB_UNSUPPORTED_FORMAT` flags. Reading both sources keeps the
|
|
66
|
+
* diagnostic in step with the parser as `formatMap` grows.
|
|
67
|
+
*/
|
|
68
|
+
export function isHandledFormat(format: string): boolean {
|
|
69
|
+
return getSchemaType(format) !== null || specialCasedFormats.has(format)
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/**
|
|
56
73
|
* Converts an OAS primitive type string to its `PrimitiveSchemaType` equivalent.
|
|
57
74
|
* Numeric types (`number`, `integer`, `bigint`) pass through unchanged. `boolean` maps to `'boolean'`. Everything else becomes `'string'`.
|
|
@@ -63,13 +80,6 @@ export function getPrimitiveType(type: string | undefined): ast.PrimitiveSchemaT
|
|
|
63
80
|
return 'string'
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
/**
|
|
67
|
-
* Narrows a content-type string to the `MediaType` union Kubb recognizes, or returns `null`.
|
|
68
|
-
*/
|
|
69
|
-
export function getMediaType(contentType: string): ast.MediaType | null {
|
|
70
|
-
return Object.values(ast.mediaTypes).includes(contentType as ast.MediaType) ? (contentType as ast.MediaType) : null
|
|
71
|
-
}
|
|
72
|
-
|
|
73
83
|
export type OperationsOptions = {
|
|
74
84
|
contentType?: ContentType
|
|
75
85
|
}
|
|
@@ -86,7 +96,7 @@ export type OperationsOptions = {
|
|
|
86
96
|
* ```
|
|
87
97
|
*/
|
|
88
98
|
export function getParameters(document: Document, operation: Operation): Array<ParameterObject> {
|
|
89
|
-
const resolveParams = (params: unknown
|
|
99
|
+
const resolveParams = (params: Array<unknown>): Array<ParameterObject> =>
|
|
90
100
|
params.map((p) => dereferenceWithRef(document, p)).filter((p): p is ParameterObject => !!p && typeof p === 'object' && 'in' in p && 'name' in p)
|
|
91
101
|
|
|
92
102
|
const operationParams = resolveParams(operation.schema?.parameters || [])
|
|
@@ -108,7 +118,7 @@ export function getParameters(document: Document, operation: Operation): Array<P
|
|
|
108
118
|
return Array.from(paramMap.values())
|
|
109
119
|
}
|
|
110
120
|
|
|
111
|
-
function getResponseBody(responseBody: boolean | ResponseObject, contentType?: string): MediaTypeObject | false | [string, MediaTypeObject, ...string
|
|
121
|
+
function getResponseBody(responseBody: boolean | ResponseObject, contentType?: string): MediaTypeObject | false | [string, MediaTypeObject, ...Array<string>] {
|
|
112
122
|
if (!responseBody) return false
|
|
113
123
|
if (isReference(responseBody)) return false
|
|
114
124
|
|
|
@@ -231,7 +241,7 @@ export type GetSchemasResult = {
|
|
|
231
241
|
/**
|
|
232
242
|
* Flattens a keyword-only `allOf` into its parent schema.
|
|
233
243
|
*
|
|
234
|
-
* Only flattens when every member is a plain fragment
|
|
244
|
+
* Only flattens when every member is a plain fragment, with no `$ref` and no structural keywords
|
|
235
245
|
* (see `structuralKeys`). Outer schema values take precedence over fragment values.
|
|
236
246
|
* Returns `null` for a `null` input, and the original schema unchanged when flattening is unsafe.
|
|
237
247
|
*
|
|
@@ -241,7 +251,7 @@ export type GetSchemasResult = {
|
|
|
241
251
|
* // { type: 'object', properties: {}, description: 'A pet' }
|
|
242
252
|
*
|
|
243
253
|
* flattenSchema({ allOf: [{ $ref: '#/components/schemas/Pet' }] })
|
|
244
|
-
* // returned unchanged
|
|
254
|
+
* // returned unchanged, contains a $ref
|
|
245
255
|
* ```
|
|
246
256
|
*/
|
|
247
257
|
/**
|
|
@@ -260,7 +270,7 @@ function hasStructuralKeywords(fragment: SchemaObject): boolean {
|
|
|
260
270
|
export function flattenSchema(schema: SchemaObject | null): SchemaObject | null {
|
|
261
271
|
if (!schema?.allOf || schema.allOf.length === 0) return schema ?? null
|
|
262
272
|
|
|
263
|
-
const allOfFragments = schema.allOf as SchemaObject
|
|
273
|
+
const allOfFragments = schema.allOf as Array<SchemaObject>
|
|
264
274
|
if (allOfFragments.some((item) => isRef(item))) return schema
|
|
265
275
|
if (allOfFragments.some(hasStructuralKeywords)) return schema
|
|
266
276
|
|
|
@@ -281,7 +291,7 @@ export function flattenSchema(schema: SchemaObject | null): SchemaObject | null
|
|
|
281
291
|
/**
|
|
282
292
|
* Extracts the inline schema from a media-type `content` map.
|
|
283
293
|
*
|
|
284
|
-
* Prefers `preferredContentType` when given
|
|
294
|
+
* Prefers `preferredContentType` when given, otherwise uses the first key in the map.
|
|
285
295
|
* Returns `null` when `content` is absent, the schema is missing, or the schema is a `$ref`.
|
|
286
296
|
*
|
|
287
297
|
* @example
|
|
@@ -305,27 +315,25 @@ export function extractSchemaFromContent(content: Record<string, unknown> | unde
|
|
|
305
315
|
/**
|
|
306
316
|
* Walks a schema tree and collects the names of all `#/components/schemas/<name>` `$ref`s.
|
|
307
317
|
*/
|
|
308
|
-
function collectRefs(schema: unknown
|
|
318
|
+
function* collectRefs(schema: unknown): Generator<string, void, undefined> {
|
|
309
319
|
if (Array.isArray(schema)) {
|
|
310
|
-
for (const item of schema) collectRefs(item
|
|
311
|
-
return
|
|
320
|
+
for (const item of schema) yield* collectRefs(item)
|
|
321
|
+
return
|
|
312
322
|
}
|
|
313
323
|
|
|
314
324
|
if (schema && typeof schema === 'object') {
|
|
315
325
|
for (const key in schema) {
|
|
316
326
|
const value = (schema as Record<string, unknown>)[key]
|
|
317
|
-
if (key === '$ref' && typeof value === 'string') {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
327
|
+
if (!(key === '$ref' && typeof value === 'string')) {
|
|
328
|
+
yield* collectRefs(value)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
if (value.startsWith(SCHEMA_REF_PREFIX)) {
|
|
332
|
+
const name = value.slice(SCHEMA_REF_PREFIX.length)
|
|
333
|
+
if (name) yield name
|
|
324
334
|
}
|
|
325
335
|
}
|
|
326
336
|
}
|
|
327
|
-
|
|
328
|
-
return refs
|
|
329
337
|
}
|
|
330
338
|
|
|
331
339
|
/**
|
|
@@ -341,13 +349,13 @@ function collectRefs(schema: unknown, refs = new Set<string>()): Set<string> {
|
|
|
341
349
|
* ```
|
|
342
350
|
*/
|
|
343
351
|
export function sortSchemas(schemas: Record<string, SchemaObject>): Record<string, SchemaObject> {
|
|
344
|
-
const deps = new Map<string, string
|
|
352
|
+
const deps = new Map<string, Array<string>>()
|
|
345
353
|
|
|
346
354
|
for (const [name, schema] of Object.entries(schemas)) {
|
|
347
|
-
deps.set(name,
|
|
355
|
+
deps.set(name, [...new Set(collectRefs(schema))])
|
|
348
356
|
}
|
|
349
357
|
|
|
350
|
-
const sorted: string
|
|
358
|
+
const sorted: Array<string> = []
|
|
351
359
|
const visited = new Set<string>()
|
|
352
360
|
|
|
353
361
|
function visit(name: string, stack: Set<string>) {
|
|
@@ -404,7 +412,7 @@ function resolveSchemaRef(document: Document, schema: SchemaObject): SchemaObjec
|
|
|
404
412
|
export function getSchemas(document: Document, { contentType }: GetSchemasOptions): GetSchemasResult {
|
|
405
413
|
const components = document.components
|
|
406
414
|
|
|
407
|
-
const candidates: SchemaWithMetadata
|
|
415
|
+
const candidates: Array<SchemaWithMetadata> = [
|
|
408
416
|
...Object.entries((components?.schemas as Record<string, SchemaObject>) ?? {}).map(([name, schema]) => ({
|
|
409
417
|
schema: resolveSchemaRef(document, schema),
|
|
410
418
|
source: 'schemas' as const,
|
|
@@ -426,7 +434,7 @@ export function getSchemas(document: Document, { contentType }: GetSchemasOption
|
|
|
426
434
|
),
|
|
427
435
|
]
|
|
428
436
|
|
|
429
|
-
const normalizedNames = new Map<string, SchemaWithMetadata
|
|
437
|
+
const normalizedNames = new Map<string, Array<SchemaWithMetadata>>()
|
|
430
438
|
for (const item of candidates) {
|
|
431
439
|
const key = pascalCase(item.originalName)
|
|
432
440
|
const bucket = normalizedNames.get(key) ?? []
|
|
@@ -514,15 +522,16 @@ export function buildSchemaNode(schema: SchemaObject, name: string | null | unde
|
|
|
514
522
|
writeOnly: schema.writeOnly,
|
|
515
523
|
default: defaultValue,
|
|
516
524
|
example: schema.example,
|
|
525
|
+
format: schema.format,
|
|
517
526
|
} as const
|
|
518
527
|
}
|
|
519
528
|
|
|
520
529
|
/**
|
|
521
530
|
* Returns all request body content type keys for an operation.
|
|
522
531
|
*
|
|
523
|
-
* The requestBody is dereferenced
|
|
524
|
-
*
|
|
525
|
-
*
|
|
532
|
+
* The requestBody is dereferenced in place when it is a `$ref` (the same mutation that
|
|
533
|
+
* `getRequestSchema` already performs), so the returned list accurately reflects the
|
|
534
|
+
* available content types even for referenced bodies.
|
|
526
535
|
*
|
|
527
536
|
* @example
|
|
528
537
|
* ```ts
|
|
@@ -530,7 +539,7 @@ export function buildSchemaNode(schema: SchemaObject, name: string | null | unde
|
|
|
530
539
|
* // ['application/json', 'multipart/form-data']
|
|
531
540
|
* ```
|
|
532
541
|
*/
|
|
533
|
-
export function getRequestBodyContentTypes(document: Document, operation: Operation): string
|
|
542
|
+
export function getRequestBodyContentTypes(document: Document, operation: Operation): Array<string> {
|
|
534
543
|
if (operation.schema.requestBody) {
|
|
535
544
|
operation.schema.requestBody = dereferenceWithRef(document, operation.schema.requestBody)
|
|
536
545
|
}
|
|
@@ -539,6 +548,36 @@ export function getRequestBodyContentTypes(document: Document, operation: Operat
|
|
|
539
548
|
if (!body) return []
|
|
540
549
|
|
|
541
550
|
// dereferenceWithRef keeps $ref but spreads all resolved fields (including `content`).
|
|
542
|
-
// Do not bail out on isReference
|
|
551
|
+
// Do not bail out on isReference, the content is already present on the merged object.
|
|
552
|
+
return body.content ? Object.keys(body.content) : []
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Returns all response content type keys for an operation at a given status code.
|
|
557
|
+
*
|
|
558
|
+
* Response `$ref`s are resolved in place first (the same mutation `getResponseSchema` performs),
|
|
559
|
+
* so the returned list reflects the available content types even for referenced responses.
|
|
560
|
+
*
|
|
561
|
+
* @example
|
|
562
|
+
* ```ts
|
|
563
|
+
* getResponseBodyContentTypes(document, operation, 200)
|
|
564
|
+
* // ['application/json', 'application/xml']
|
|
565
|
+
* ```
|
|
566
|
+
*/
|
|
567
|
+
export function getResponseBodyContentTypes(document: Document, operation: Operation, statusCode: string | number): Array<string> {
|
|
568
|
+
if (operation.schema.responses) {
|
|
569
|
+
const responses = operation.schema.responses
|
|
570
|
+
for (const key in responses) {
|
|
571
|
+
const schema = responses[key]
|
|
572
|
+
if (schema && isReference(schema)) {
|
|
573
|
+
responses[key] = resolveRef<any>(document, schema.$ref)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const responseObj = operation.getResponseByStatusCode(statusCode)
|
|
579
|
+
if (!responseObj || typeof responseObj !== 'object' || isReference(responseObj)) return []
|
|
580
|
+
|
|
581
|
+
const body = responseObj as { content?: Record<string, unknown> }
|
|
543
582
|
return body.content ? Object.keys(body.content) : []
|
|
544
583
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Diagnostics } from '@kubb/core'
|
|
2
|
+
import type { ast } from '@kubb/core'
|
|
3
|
+
import { isHandledFormat } from './resolvers.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reports the advisory diagnostics (`KUBB_UNSUPPORTED_FORMAT`, `KUBB_DEPRECATED`) for one
|
|
7
|
+
* top-level schema. Walks the node the parser produced during `preScan`, threading the RFC 6901
|
|
8
|
+
* pointer as it descends so a nested field reports against its full path
|
|
9
|
+
* (`#/components/schemas/Pet/properties/owner/properties/name`). Refs are not followed, so the
|
|
10
|
+
* resolved schema is reported under its own walk. Reports land in the active build run, are a
|
|
11
|
+
* no-op outside one, and repeats are deduped by the build.
|
|
12
|
+
*/
|
|
13
|
+
export function reportSchemaDiagnostics({ node, name }: { node: ast.SchemaNode; name: string }): void {
|
|
14
|
+
visit(node, `#/components/schemas/${escapePointerToken(name)}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Escapes a single JSON pointer reference token per RFC 6901 (`~` → `~0`, `/` → `~1`), so a
|
|
19
|
+
* property name with those characters maps to a distinct pointer instead of colliding in the dedupe.
|
|
20
|
+
*/
|
|
21
|
+
function escapePointerToken(token: string): string {
|
|
22
|
+
return token.replace(/~/g, '~0').replace(/\//g, '~1')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function visit(node: ast.SchemaNode, pointer: string): void {
|
|
26
|
+
if (node.deprecated) {
|
|
27
|
+
Diagnostics.report({
|
|
28
|
+
code: Diagnostics.code.deprecated,
|
|
29
|
+
severity: 'info',
|
|
30
|
+
message: 'This schema is marked as deprecated.',
|
|
31
|
+
location: { kind: 'schema', pointer },
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof node.format === 'string' && !isHandledFormat(node.format)) {
|
|
36
|
+
Diagnostics.report({
|
|
37
|
+
code: Diagnostics.code.unsupportedFormat,
|
|
38
|
+
severity: 'warning',
|
|
39
|
+
message: `Kubb does not map the format "${node.format}" to a specific type, so it falls back to the base type.`,
|
|
40
|
+
help: `Use a format Kubb supports, or handle "${node.format}" with a custom parser or plugin.`,
|
|
41
|
+
location: { kind: 'schema', pointer },
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (node.type === 'object') {
|
|
46
|
+
for (const property of node.properties) {
|
|
47
|
+
visit(property.schema, `${pointer}/properties/${escapePointerToken(property.name)}`)
|
|
48
|
+
}
|
|
49
|
+
if (node.additionalProperties && typeof node.additionalProperties === 'object') {
|
|
50
|
+
visit(node.additionalProperties, `${pointer}/additionalProperties`)
|
|
51
|
+
}
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (node.type === 'array') {
|
|
56
|
+
for (const item of node.items ?? []) {
|
|
57
|
+
visit(item, `${pointer}/items`)
|
|
58
|
+
}
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (node.type === 'tuple') {
|
|
63
|
+
// Each tuple position has its own pointer, so index them. A shared `/items` would collapse
|
|
64
|
+
// distinct diagnostics in the dedupe.
|
|
65
|
+
for (const [index, item] of (node.items ?? []).entries()) {
|
|
66
|
+
visit(item, `${pointer}/items/${index}`)
|
|
67
|
+
}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (node.type === 'union' || node.type === 'intersection') {
|
|
72
|
+
for (const [index, member] of (node.members ?? []).entries()) {
|
|
73
|
+
visit(member, `${pointer}/members/${index}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { ast } from '@kubb/core'
|
|
2
|
+
import type BaseOas from 'oas'
|
|
3
|
+
import { SCHEMA_REF_PREFIX } from './constants.ts'
|
|
4
|
+
import { buildDiscriminatorChildMap, patchDiscriminatorNode } from './discriminator.ts'
|
|
5
|
+
import type { SchemaParser } from './parser.ts'
|
|
6
|
+
import { resolveServerUrl } from './resolvers.ts'
|
|
7
|
+
import { reportSchemaDiagnostics } from './schemaDiagnostics.ts'
|
|
8
|
+
import type { DiscriminatorTarget } from './discriminator.ts'
|
|
9
|
+
import type { AdapterOas, Document, SchemaObject } from './types.ts'
|
|
10
|
+
|
|
11
|
+
export type PreScanResult = {
|
|
12
|
+
refAliasMap: Map<string, ast.SchemaNode>
|
|
13
|
+
enumNames: Array<string>
|
|
14
|
+
circularNames: Array<string>
|
|
15
|
+
discriminatorChildMap: Map<string, DiscriminatorTarget> | null
|
|
16
|
+
dedupePlan: ast.DedupePlan | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds the deduplication plan from the already-parsed top-level schema nodes plus a
|
|
21
|
+
* single extra parse pass over operations (so duplicates in request/response bodies are seen).
|
|
22
|
+
*
|
|
23
|
+
* Only enums and objects are candidates, and object shapes that reference a circular schema are
|
|
24
|
+
* rejected to avoid hoisting recursive structures. Inline shapes reuse their context-derived
|
|
25
|
+
* name (collision-resolved against existing component names); shapes without a name stay inline.
|
|
26
|
+
*/
|
|
27
|
+
function createDedupePlan({
|
|
28
|
+
schemaNodes,
|
|
29
|
+
operationNodes,
|
|
30
|
+
schemaNames,
|
|
31
|
+
circularNames,
|
|
32
|
+
}: {
|
|
33
|
+
schemaNodes: Array<ast.SchemaNode>
|
|
34
|
+
operationNodes: Array<ast.OperationNode>
|
|
35
|
+
schemaNames: Array<string>
|
|
36
|
+
circularNames: Array<string>
|
|
37
|
+
}): ast.DedupePlan {
|
|
38
|
+
const circularSchemas = new Set(circularNames)
|
|
39
|
+
const usedNames = new Set(schemaNames)
|
|
40
|
+
|
|
41
|
+
return ast.buildDedupePlan([...schemaNodes, ...operationNodes], {
|
|
42
|
+
isCandidate: (node) => {
|
|
43
|
+
if (node.type === 'enum') return true
|
|
44
|
+
if (node.type !== 'object') return false
|
|
45
|
+
// Skip object shapes that are part of a circular chain, hoisting them would break the cycle.
|
|
46
|
+
if (node.name && circularSchemas.has(node.name)) return false
|
|
47
|
+
return !ast.containsCircularRef(node, { circularSchemas })
|
|
48
|
+
},
|
|
49
|
+
nameFor: (node) => {
|
|
50
|
+
const base = node.name
|
|
51
|
+
if (!base) return null
|
|
52
|
+
|
|
53
|
+
let name = base
|
|
54
|
+
let counter = 2
|
|
55
|
+
while (usedNames.has(name)) {
|
|
56
|
+
name = `${base}${counter++}`
|
|
57
|
+
}
|
|
58
|
+
usedNames.add(name)
|
|
59
|
+
return name
|
|
60
|
+
},
|
|
61
|
+
refFor: (name) => `${SCHEMA_REF_PREFIX}${name}`,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads the server URL from the document's `servers` array at `serverIndex`,
|
|
67
|
+
* interpolating any `serverVariables` into the URL template.
|
|
68
|
+
*
|
|
69
|
+
* Returns `null` when `serverIndex` is omitted or out of range.
|
|
70
|
+
*
|
|
71
|
+
* @example Resolve the first server
|
|
72
|
+
* `resolveBaseUrl({ document, serverIndex: 0 })`
|
|
73
|
+
*
|
|
74
|
+
* @example Override a path variable
|
|
75
|
+
* `resolveBaseUrl({ document, serverIndex: 0, serverVariables: { version: 'v2' } })`
|
|
76
|
+
*/
|
|
77
|
+
export function resolveBaseUrl({
|
|
78
|
+
document,
|
|
79
|
+
serverIndex,
|
|
80
|
+
serverVariables,
|
|
81
|
+
}: {
|
|
82
|
+
document: Document
|
|
83
|
+
serverIndex?: number
|
|
84
|
+
serverVariables?: Record<string, string>
|
|
85
|
+
}): string | null {
|
|
86
|
+
const server = serverIndex !== undefined ? document.servers?.at(serverIndex) : undefined
|
|
87
|
+
return server?.url ? resolveServerUrl(server, serverVariables) : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parses every schema once to build the lookup structures that streaming needs upfront.
|
|
92
|
+
*
|
|
93
|
+
* Three things happen in this single pass:
|
|
94
|
+
* - `refAliasMap` records schemas that are pure `$ref` aliases so the streaming pass can inline them.
|
|
95
|
+
* - `enumNames` collects the names of every enum schema so plugins skip re-scanning the stream.
|
|
96
|
+
* - `circularNames` runs cycle detection, which requires all nodes in memory simultaneously.
|
|
97
|
+
* The `allNodes` array is local and drops out of scope as soon as this function returns.
|
|
98
|
+
*
|
|
99
|
+
* After this call, only `refAliasMap` and `discriminatorChildMap` stay alive in the adapter closure.
|
|
100
|
+
* Both are proportional to the number of aliases or discriminator parents, not total schema count.
|
|
101
|
+
*
|
|
102
|
+
* Each schema is parsed again during the streaming pass. This is intentional.
|
|
103
|
+
* Holding the parsed nodes in memory here would defeat the streaming memory benefit.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* const { refAliasMap, enumNames, circularNames } = preScan({
|
|
108
|
+
* schemas,
|
|
109
|
+
* parseSchema,
|
|
110
|
+
* parserOptions,
|
|
111
|
+
* discriminator: 'strict',
|
|
112
|
+
* })
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export function preScan({
|
|
116
|
+
schemas,
|
|
117
|
+
parseSchema,
|
|
118
|
+
parseOperation,
|
|
119
|
+
baseOas,
|
|
120
|
+
parserOptions,
|
|
121
|
+
discriminator,
|
|
122
|
+
dedupe,
|
|
123
|
+
}: {
|
|
124
|
+
schemas: Record<string, SchemaObject>
|
|
125
|
+
parseSchema: (entry: { schema: SchemaObject; name: string }, options: ast.ParserOptions) => ast.SchemaNode
|
|
126
|
+
parseOperation: SchemaParser['parseOperation']
|
|
127
|
+
baseOas: BaseOas
|
|
128
|
+
parserOptions: ast.ParserOptions
|
|
129
|
+
discriminator: AdapterOas['options']['discriminator']
|
|
130
|
+
dedupe: boolean
|
|
131
|
+
}): PreScanResult {
|
|
132
|
+
const allNodes: Array<ast.SchemaNode> = []
|
|
133
|
+
const refAliasMap = new Map<string, ast.SchemaNode>()
|
|
134
|
+
const enumNames: Array<string> = []
|
|
135
|
+
const discriminatorParentNodes: Array<ast.SchemaNode> = []
|
|
136
|
+
|
|
137
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
138
|
+
const node = parseSchema({ schema, name }, parserOptions)
|
|
139
|
+
allNodes.push(node)
|
|
140
|
+
reportSchemaDiagnostics({ node, name })
|
|
141
|
+
if (node.type === 'ref' && node.name && node.name !== name) {
|
|
142
|
+
refAliasMap.set(name, node)
|
|
143
|
+
}
|
|
144
|
+
if (ast.narrowSchema(node, ast.schemaTypes.enum) && node.name) {
|
|
145
|
+
enumNames.push(node.name)
|
|
146
|
+
}
|
|
147
|
+
if (discriminator === 'inherit' && (schema.oneOf ?? schema.anyOf) && schema.discriminator?.propertyName) {
|
|
148
|
+
discriminatorParentNodes.push(node)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const circularNames = [...ast.findCircularSchemas(allNodes)]
|
|
153
|
+
const discriminatorChildMap = discriminatorParentNodes.length > 0 ? buildDiscriminatorChildMap(discriminatorParentNodes) : null
|
|
154
|
+
|
|
155
|
+
let dedupePlan: ast.DedupePlan | null = null
|
|
156
|
+
if (dedupe) {
|
|
157
|
+
// One extra parse pass over operations so duplicates in request/response bodies are seen.
|
|
158
|
+
// Reuses the already-parsed `allNodes` for schemas, no second schema parse.
|
|
159
|
+
const operationNodes: Array<ast.OperationNode> = []
|
|
160
|
+
for (const methods of Object.values(baseOas.getPaths())) {
|
|
161
|
+
for (const operation of Object.values(methods)) {
|
|
162
|
+
if (!operation) continue
|
|
163
|
+
const operationNode = parseOperation(parserOptions, operation)
|
|
164
|
+
if (operationNode) operationNodes.push(operationNode)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
dedupePlan = createDedupePlan({ schemaNodes: allNodes, operationNodes, schemaNames: Object.keys(schemas), circularNames })
|
|
169
|
+
|
|
170
|
+
for (const definition of dedupePlan.hoisted) {
|
|
171
|
+
if (definition.type === 'enum' && definition.name) enumNames.push(definition.name)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { refAliasMap, enumNames, circularNames, discriminatorChildMap, dedupePlan }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Creates a lazy `InputStreamNode` from already-resolved adapter state.
|
|
180
|
+
*
|
|
181
|
+
* The schema and operation iterables each start a fresh parse pass on every
|
|
182
|
+
* `[Symbol.asyncIterator]()` call. This lets multiple plugins consume the same
|
|
183
|
+
* stream object independently without sharing a cursor or holding all nodes in memory.
|
|
184
|
+
*
|
|
185
|
+
* Ref aliases in `refAliasMap` are inlined during iteration: an alias entry is replaced
|
|
186
|
+
* with its target's parsed node (but keeps the alias name) so plugins never receive bare `ref` nodes.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```ts
|
|
190
|
+
* const streamNode = createInputStream({ schemas, parseSchema, parseOperation, baseOas, parserOptions, refAliasMap, discriminatorChildMap, meta })
|
|
191
|
+
* for await (const schema of streamNode.schemas) {
|
|
192
|
+
* // each call to for-await restarts from the first schema
|
|
193
|
+
* }
|
|
194
|
+
* ```
|
|
195
|
+
*/
|
|
196
|
+
export function createInputStream({
|
|
197
|
+
schemas,
|
|
198
|
+
parseSchema,
|
|
199
|
+
parseOperation,
|
|
200
|
+
baseOas,
|
|
201
|
+
parserOptions,
|
|
202
|
+
refAliasMap,
|
|
203
|
+
discriminatorChildMap,
|
|
204
|
+
dedupePlan,
|
|
205
|
+
meta,
|
|
206
|
+
}: {
|
|
207
|
+
schemas: Record<string, SchemaObject>
|
|
208
|
+
parseSchema: SchemaParser['parseSchema']
|
|
209
|
+
parseOperation: SchemaParser['parseOperation']
|
|
210
|
+
baseOas: BaseOas
|
|
211
|
+
parserOptions: ast.ParserOptions
|
|
212
|
+
refAliasMap: Map<string, ast.SchemaNode>
|
|
213
|
+
discriminatorChildMap: Map<string, DiscriminatorTarget> | null
|
|
214
|
+
dedupePlan: ast.DedupePlan | null
|
|
215
|
+
meta: ast.InputMeta
|
|
216
|
+
}): ast.InputStreamNode {
|
|
217
|
+
// Rewrites a top-level schema against the dedupe plan: a structurally identical sibling
|
|
218
|
+
// becomes a `ref` alias to the canonical one (keeping its own name); otherwise nested
|
|
219
|
+
// duplicates are collapsed while the schema's own root is preserved.
|
|
220
|
+
const rewriteTopLevelSchema = (node: ast.SchemaNode): ast.SchemaNode => {
|
|
221
|
+
if (!dedupePlan) return node
|
|
222
|
+
|
|
223
|
+
const canonical = dedupePlan.canonicalBySignature.get(ast.schemaSignature(node))
|
|
224
|
+
if (canonical && canonical.name !== node.name) {
|
|
225
|
+
return ast.createSchema({
|
|
226
|
+
type: 'ref',
|
|
227
|
+
name: node.name ?? null,
|
|
228
|
+
ref: canonical.ref,
|
|
229
|
+
description: node.description,
|
|
230
|
+
deprecated: node.deprecated,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return ast.applyDedupe(node, dedupePlan.canonicalBySignature, true)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const schemasIterable: AsyncIterable<ast.SchemaNode> = {
|
|
238
|
+
[Symbol.asyncIterator]() {
|
|
239
|
+
return (async function* () {
|
|
240
|
+
// Hoisted canonical definitions are emitted first so the schema list owns the shared shapes.
|
|
241
|
+
if (dedupePlan) {
|
|
242
|
+
for (const definition of dedupePlan.hoisted) yield definition
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
246
|
+
// Inline ref aliases: replace the alias entry with its target's parsed node
|
|
247
|
+
// (keeping the alias name). Skip the first parse entirely for alias entries
|
|
248
|
+
// since that result is never used.
|
|
249
|
+
const alias = refAliasMap.get(name)
|
|
250
|
+
if (alias?.name && schemas[alias.name]) {
|
|
251
|
+
yield rewriteTopLevelSchema({ ...parseSchema({ schema: schemas[alias.name]!, name: alias.name }, parserOptions), name })
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const parsed = parseSchema({ schema, name }, parserOptions)
|
|
256
|
+
const node = discriminatorChildMap?.get(name) ? patchDiscriminatorNode(parsed, discriminatorChildMap.get(name)!) : parsed
|
|
257
|
+
yield rewriteTopLevelSchema(node)
|
|
258
|
+
}
|
|
259
|
+
})()
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const operationsIterable: AsyncIterable<ast.OperationNode> = {
|
|
264
|
+
[Symbol.asyncIterator]() {
|
|
265
|
+
return (async function* () {
|
|
266
|
+
for (const methods of Object.values(baseOas.getPaths())) {
|
|
267
|
+
for (const operation of Object.values(methods)) {
|
|
268
|
+
if (!operation) continue
|
|
269
|
+
const node = parseOperation(parserOptions, operation)
|
|
270
|
+
if (node) yield dedupePlan ? ast.applyDedupe(node, dedupePlan.canonicalBySignature) : node
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
})()
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return ast.createStreamInput(schemasIterable, operationsIterable, meta)
|
|
278
|
+
}
|