@kubb/adapter-oas 5.0.0-beta.6 → 5.0.0-beta.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stream.ts ADDED
@@ -0,0 +1,283 @@
1
+ import { containsCircularRef, findCircularSchemas } from '@kubb/ast/utils'
2
+ import { ast } from '@kubb/core'
3
+ import { SCHEMA_REF_PREFIX } from './constants.ts'
4
+ import { buildDiscriminatorChildMap, patchDiscriminatorNode } from './discriminator.ts'
5
+ import { getOperations } from './operation.ts'
6
+ import type { SchemaParser } from './parser.ts'
7
+ import { resolveServerUrl } from './resolvers.ts'
8
+ import { reportSchemaDiagnostics } from './schemaDiagnostics.ts'
9
+ import type { DiscriminatorTarget } from './discriminator.ts'
10
+ import type { AdapterOas, Document, SchemaObject } from './types.ts'
11
+
12
+ export type PreScanResult = {
13
+ refAliasMap: Map<string, ast.SchemaNode>
14
+ enumNames: Array<string>
15
+ circularNames: Array<string>
16
+ discriminatorChildMap: Map<string, DiscriminatorTarget> | null
17
+ dedupePlan: ast.DedupePlan | null
18
+ }
19
+
20
+ /**
21
+ * Builds the deduplication plan from the already-parsed top-level schema nodes plus a
22
+ * single extra parse pass over operations (so duplicates in request/response bodies are seen).
23
+ *
24
+ * Only enums and objects are candidates, and object shapes that reference a circular schema are
25
+ * rejected to avoid hoisting recursive structures. Inline shapes reuse their context-derived
26
+ * name (collision-resolved against existing component names). Shapes without a name stay inline.
27
+ */
28
+ function createDedupePlan({
29
+ schemaNodes,
30
+ operationNodes,
31
+ schemaNames,
32
+ circularNames,
33
+ }: {
34
+ schemaNodes: Array<ast.SchemaNode>
35
+ operationNodes: Array<ast.OperationNode>
36
+ schemaNames: Array<string>
37
+ circularNames: Array<string>
38
+ }): ast.DedupePlan {
39
+ const circularSchemas = new Set(circularNames)
40
+ const usedNames = new Set(schemaNames)
41
+
42
+ return ast.buildDedupePlan([...schemaNodes, ...operationNodes], {
43
+ isCandidate: (node) => {
44
+ if (node.type === 'enum') return true
45
+ if (node.type !== 'object') return false
46
+ // Skip object shapes that are part of a circular chain, hoisting them would break the cycle.
47
+ if (node.name && circularSchemas.has(node.name)) return false
48
+ return !containsCircularRef(node, { circularSchemas })
49
+ },
50
+ nameFor: (node) => {
51
+ const base = node.name
52
+ if (!base) return null
53
+
54
+ let name = base
55
+ let counter = 2
56
+ while (usedNames.has(name)) {
57
+ name = `${base}${counter++}`
58
+ }
59
+ usedNames.add(name)
60
+ return name
61
+ },
62
+ refFor: (name) => `${SCHEMA_REF_PREFIX}${name}`,
63
+ })
64
+ }
65
+
66
+ /**
67
+ * Reads the server URL from the document's `servers` array at `serverIndex`,
68
+ * interpolating any `serverVariables` into the URL template.
69
+ *
70
+ * Returns `null` when `serverIndex` is omitted or out of range.
71
+ *
72
+ * @example Resolve the first server
73
+ * `resolveBaseUrl({ document, serverIndex: 0 })`
74
+ *
75
+ * @example Override a path variable
76
+ * `resolveBaseUrl({ document, serverIndex: 0, serverVariables: { version: 'v2' } })`
77
+ */
78
+ export function resolveBaseUrl({
79
+ document,
80
+ serverIndex,
81
+ serverVariables,
82
+ }: {
83
+ document: Document
84
+ serverIndex?: number
85
+ serverVariables?: Record<string, string>
86
+ }): string | null {
87
+ const server = serverIndex !== undefined ? document.servers?.at(serverIndex) : undefined
88
+ return server?.url ? resolveServerUrl(server, serverVariables) : null
89
+ }
90
+
91
+ /**
92
+ * Parses every schema once to build the lookup structures that streaming needs upfront.
93
+ *
94
+ * Three things happen in this single pass:
95
+ * - `refAliasMap` records schemas that are pure `$ref` aliases so the streaming pass can inline them.
96
+ * - `enumNames` collects the names of every enum schema so plugins skip re-scanning the stream.
97
+ * - `circularNames` runs cycle detection, which requires all nodes in memory simultaneously.
98
+ * The `allNodes` array is local and drops out of scope as soon as this function returns.
99
+ *
100
+ * After this call, only `refAliasMap` and `discriminatorChildMap` stay alive in the adapter closure.
101
+ * Both are proportional to the number of aliases or discriminator parents, not total schema count.
102
+ *
103
+ * Each schema is parsed again during the streaming pass. This is intentional.
104
+ * Holding the parsed nodes in memory here would defeat the streaming memory benefit.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * const { refAliasMap, enumNames, circularNames } = preScan({
109
+ * schemas,
110
+ * parseSchema,
111
+ * parserOptions,
112
+ * discriminator: 'strict',
113
+ * })
114
+ * ```
115
+ */
116
+ export function preScan({
117
+ schemas,
118
+ parseSchema,
119
+ parseOperation,
120
+ document,
121
+ parserOptions,
122
+ discriminator,
123
+ dedupe,
124
+ }: {
125
+ schemas: Record<string, SchemaObject>
126
+ parseSchema: (entry: { schema: SchemaObject; name: string }, options: ast.ParserOptions) => ast.SchemaNode
127
+ parseOperation: SchemaParser['parseOperation']
128
+ document: Document
129
+ parserOptions: ast.ParserOptions
130
+ discriminator: AdapterOas['options']['discriminator']
131
+ dedupe: boolean
132
+ }): PreScanResult {
133
+ const allNodes: Array<ast.SchemaNode> = []
134
+ const refAliasMap = new Map<string, ast.SchemaNode>()
135
+ const enumNames: Array<string> = []
136
+ const discriminatorParentNodes: Array<ast.SchemaNode> = []
137
+
138
+ for (const [name, schema] of Object.entries(schemas)) {
139
+ const node = parseSchema({ schema, name }, parserOptions)
140
+ allNodes.push(node)
141
+ reportSchemaDiagnostics({ node, name })
142
+ if (node.type === 'ref' && node.name && node.name !== name) {
143
+ refAliasMap.set(name, node)
144
+ }
145
+ if (ast.narrowSchema(node, ast.schemaTypes.enum) && node.name) {
146
+ enumNames.push(node.name)
147
+ }
148
+ if (discriminator === 'inherit' && (schema.oneOf ?? schema.anyOf) && schema.discriminator?.propertyName) {
149
+ discriminatorParentNodes.push(node)
150
+ }
151
+ }
152
+
153
+ const circularNames = [...findCircularSchemas(allNodes)]
154
+ const discriminatorChildMap = discriminatorParentNodes.length > 0 ? buildDiscriminatorChildMap(discriminatorParentNodes) : null
155
+
156
+ let dedupePlan: ast.DedupePlan | null = null
157
+ if (dedupe) {
158
+ // One extra parse pass over operations so duplicates in request/response bodies are seen.
159
+ // Reuses the already-parsed `allNodes` for schemas, no second schema parse.
160
+ const operationNodes: Array<ast.OperationNode> = []
161
+ for (const operation of getOperations(document)) {
162
+ const operationNode = parseOperation(parserOptions, operation)
163
+ if (operationNode) operationNodes.push(operationNode)
164
+ }
165
+
166
+ dedupePlan = createDedupePlan({ schemaNodes: allNodes, operationNodes, schemaNames: Object.keys(schemas), circularNames })
167
+
168
+ for (const definition of dedupePlan.hoisted) {
169
+ if (definition.type === 'enum' && definition.name) enumNames.push(definition.name)
170
+ }
171
+ }
172
+
173
+ // Enum names that duplicate an earlier schema's content are never emitted, so they are not
174
+ // advertised to plugins either.
175
+ const aliasNames = dedupePlan?.aliasNames
176
+ const emittedEnumNames = aliasNames && aliasNames.size > 0 ? enumNames.filter((name) => !aliasNames.has(name)) : enumNames
177
+
178
+ return { refAliasMap, enumNames: emittedEnumNames, circularNames, discriminatorChildMap, dedupePlan }
179
+ }
180
+
181
+ /**
182
+ * Creates a lazy `InputNode<true>` from already-resolved adapter state.
183
+ *
184
+ * The schema and operation iterables each start a fresh parse pass on every
185
+ * `[Symbol.asyncIterator]()` call. This lets multiple plugins consume the same
186
+ * stream object independently without sharing a cursor or holding all nodes in memory.
187
+ *
188
+ * Ref aliases in `refAliasMap` are inlined during iteration: an alias entry is replaced
189
+ * with its target's parsed node (but keeps the alias name) so plugins never receive bare `ref` nodes.
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * const streamNode = createInputStream({ schemas, parseSchema, parseOperation, document, parserOptions, refAliasMap, discriminatorChildMap, meta })
194
+ * for await (const schema of streamNode.schemas) {
195
+ * // each call to for-await restarts from the first schema
196
+ * }
197
+ * ```
198
+ */
199
+ export function createInputStream({
200
+ schemas,
201
+ parseSchema,
202
+ parseOperation,
203
+ document,
204
+ parserOptions,
205
+ refAliasMap,
206
+ discriminatorChildMap,
207
+ dedupePlan,
208
+ meta,
209
+ }: {
210
+ schemas: Record<string, SchemaObject>
211
+ parseSchema: SchemaParser['parseSchema']
212
+ parseOperation: SchemaParser['parseOperation']
213
+ document: Document
214
+ parserOptions: ast.ParserOptions
215
+ refAliasMap: Map<string, ast.SchemaNode>
216
+ discriminatorChildMap: Map<string, DiscriminatorTarget> | null
217
+ dedupePlan: ast.DedupePlan | null
218
+ meta: ast.InputMeta
219
+ }): ast.InputNode<true> {
220
+ // Rewrites a top-level schema against the dedupe plan: a structurally identical sibling
221
+ // becomes a `ref` alias to the canonical one (keeping its own name). Otherwise nested
222
+ // duplicates are collapsed while the schema's own root is preserved.
223
+ const rewriteTopLevelSchema = (node: ast.SchemaNode): ast.SchemaNode => {
224
+ if (!dedupePlan) return node
225
+
226
+ const canonical = dedupePlan.canonicalBySignature.get(ast.signatureOf(node))
227
+ if (canonical && canonical.name !== node.name) {
228
+ return ast.factory.createSchema({
229
+ type: 'ref',
230
+ name: node.name ?? null,
231
+ ref: canonical.ref,
232
+ description: node.description,
233
+ deprecated: node.deprecated,
234
+ })
235
+ }
236
+
237
+ return ast.applyDedupe(node, dedupePlan, true)
238
+ }
239
+
240
+ const schemasIterable: AsyncIterable<ast.SchemaNode> = {
241
+ [Symbol.asyncIterator]() {
242
+ return (async function* () {
243
+ // Hoisted canonical definitions are emitted first so the schema list owns the shared shapes.
244
+ if (dedupePlan) {
245
+ for (const definition of dedupePlan.hoisted) yield definition
246
+ }
247
+
248
+ for (const [name, schema] of Object.entries(schemas)) {
249
+ // A top-level schema whose content duplicates an earlier one is not emitted: every
250
+ // ref to it is repointed at the first schema with that content, so its model would
251
+ // be dead code.
252
+ if (dedupePlan?.aliasNames.has(name)) continue
253
+
254
+ // Inline ref aliases: replace the alias entry with its target's parsed node
255
+ // (keeping the alias name). Skip the first parse entirely for alias entries
256
+ // since that result is never used.
257
+ const alias = refAliasMap.get(name)
258
+ if (alias?.name && schemas[alias.name]) {
259
+ yield rewriteTopLevelSchema({ ...parseSchema({ schema: schemas[alias.name]!, name: alias.name }, parserOptions), name })
260
+ continue
261
+ }
262
+
263
+ const parsed = parseSchema({ schema, name }, parserOptions)
264
+ const node = discriminatorChildMap?.get(name) ? patchDiscriminatorNode(parsed, discriminatorChildMap.get(name)!) : parsed
265
+ yield rewriteTopLevelSchema(node)
266
+ }
267
+ })()
268
+ },
269
+ }
270
+
271
+ const operationsIterable: AsyncIterable<ast.OperationNode> = {
272
+ [Symbol.asyncIterator]() {
273
+ return (async function* () {
274
+ for (const operation of getOperations(document)) {
275
+ const node = parseOperation(parserOptions, operation)
276
+ if (node) yield dedupePlan ? ast.applyDedupe(node, dedupePlan) : node
277
+ }
278
+ })()
279
+ },
280
+ }
281
+
282
+ return ast.factory.createInput({ stream: true, schemas: schemasIterable, operations: operationsIterable, meta })
283
+ }
package/src/types.ts CHANGED
@@ -1,25 +1,13 @@
1
- // external packages
2
-
3
1
  import type { AdapterFactoryOptions } from '@kubb/core'
4
- import { ast } from '@kubb/core'
5
- import type { Operation as OASOperation } from 'oas/operation'
6
- import type {
7
- DiscriminatorObject as OASDiscriminatorObject,
8
- OASDocument,
9
- MediaTypeObject as OASMediaTypeObject,
10
- ResponseObject as OASResponseObject,
11
- SchemaObject as OASSchemaObject,
12
- } from 'oas/types'
13
- import type { OpenAPIV3 } from 'openapi-types'
2
+ import type { ast } from '@kubb/core'
3
+ import type { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema'
4
+ import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
5
+ import type { Operation } from './operation.ts'
14
6
 
15
- /**
16
- * Re-exports of `openapi-types` for use by adapter consumers.
17
- */
18
- export type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
7
+ export type { Operation }
19
8
 
20
9
  /**
21
- * Content-type string for selecting request/response schemas from an OpenAPI spec.
22
- * Supports `'application/json'` or any other media type.
10
+ * Media type used to pick a schema from an operation's request or response.
23
11
  *
24
12
  * @example
25
13
  * ```ts
@@ -41,7 +29,18 @@ export type ContentType = 'application/json' | (string & {})
41
29
  * }
42
30
  * ```
43
31
  */
44
- export type SchemaObject = OASSchemaObject & {
32
+ export type SchemaObject = {
33
+ externalDocs?: unknown
34
+ xml?: unknown
35
+ $schema?: string
36
+ deprecated?: boolean
37
+ example?: unknown
38
+ examples?: Array<unknown>
39
+ nullable?: boolean
40
+ readOnly?: boolean
41
+ writeOnly?: boolean
42
+ discriminator?: DiscriminatorObject
43
+ 'x-readme-ref-name'?: string
45
44
  /**
46
45
  * OAS 3.0 vendor extension: marks a schema as nullable without using `type: ['null', ...]`.
47
46
  */
@@ -68,44 +67,35 @@ export type SchemaObject = OASSchemaObject & {
68
67
  */
69
68
  items?: SchemaObject | ReferenceObject
70
69
  /**
71
- * Enum values for this schema (narrowed from `unknown[]`).
70
+ * Allowed values for this schema.
72
71
  */
73
72
  enum?: Array<string | number | boolean | null>
74
- }
73
+ } & (OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject | JSONSchema4 | JSONSchema6 | JSONSchema7)
75
74
 
76
75
  /**
77
- * Maps uppercase HTTP method names to lowercase for backwards compatibility.
78
- *
79
- * @example
80
- * ```ts
81
- * HttpMethods['GET'] // 'get'
82
- * HttpMethods['POST'] // 'post'
83
- * ```
76
+ * HTTP method in the lowercase form an OpenAPI path item uses for its keys.
84
77
  */
85
- export const HttpMethods = Object.fromEntries(Object.entries(ast.httpMethods).map(([lower, upper]) => [upper, lower])) as Record<
86
- Uppercase<ast.HttpMethod>,
87
- Lowercase<ast.HttpMethod>
88
- >
78
+ export type HttpMethod = Lowercase<ast.HttpMethod>
89
79
 
90
80
  /**
91
- * HTTP method as a lowercase string (`'get' | 'post' | ...`).
81
+ * Normalized OpenAPI document after parsing.
92
82
  */
93
- export type HttpMethod = Lowercase<ast.HttpMethod>
83
+ export type Document = (OpenAPIV3.Document | OpenAPIV3_1.Document) & Record<string, unknown>
94
84
 
95
85
  /**
96
- * Normalized OpenAPI document after parsing.
86
+ * Single operation object (the `get`/`post`/… entry on a path item) plus any vendor extensions.
97
87
  */
98
- export type Document = OASDocument
88
+ export type OperationObject = (OpenAPIV3.OperationObject | OpenAPIV3_1.OperationObject) & Record<string, unknown>
99
89
 
100
90
  /**
101
- * API operation extracted from an OpenAPI document.
91
+ * Path item object holding the operations and shared parameters for a single URL path.
102
92
  */
103
- export type Operation = OASOperation
93
+ export type PathItemObject = OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObject
104
94
 
105
95
  /**
106
96
  * Discriminator object for `oneOf`/`anyOf` schemas in OpenAPI.
107
97
  */
108
- export type DiscriminatorObject = OASDiscriminatorObject
98
+ export type DiscriminatorObject = OpenAPIV3.DiscriminatorObject | OpenAPIV3_1.DiscriminatorObject
109
99
 
110
100
  /**
111
101
  * OpenAPI reference object pointing to a schema definition via `$ref`.
@@ -113,18 +103,35 @@ export type DiscriminatorObject = OASDiscriminatorObject
113
103
  export type ReferenceObject = OpenAPIV3.ReferenceObject
114
104
 
115
105
  /**
116
- * OpenAPI response object from a spec that contains schema, status code, and headers.
106
+ * OpenAPI response object holding the content and headers for one status code.
107
+ */
108
+ export type ResponseObject = OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject
109
+
110
+ /**
111
+ * OpenAPI request body object that maps content types to their media type objects.
117
112
  */
118
- export type ResponseObject = OASResponseObject
113
+ export type RequestBodyObject = OpenAPIV3.RequestBodyObject | OpenAPIV3_1.RequestBodyObject
119
114
 
120
115
  /**
121
116
  * OpenAPI media type object that maps a content-type string to its schema.
122
117
  */
123
- export type MediaTypeObject = OASMediaTypeObject
118
+ export type MediaTypeObject = OpenAPIV3.MediaTypeObject | OpenAPIV3_1.MediaTypeObject
119
+
120
+ /**
121
+ * OpenAPI parameter object, narrowed so `in` is always one of the four valid locations.
122
+ */
123
+ export type ParameterObject = {
124
+ in: 'cookie' | 'header' | 'path' | 'query'
125
+ } & (OpenAPIV3.ParameterObject | OpenAPIV3_1.ParameterObject)
126
+
127
+ /**
128
+ * OpenAPI server object describing a base URL and its `{variable}` substitutions.
129
+ */
130
+ export type ServerObject = OpenAPIV3.ServerObject | OpenAPIV3_1.ServerObject
124
131
 
125
132
  /**
126
- * Configuration options for the OpenAPI adapter.
127
- * Controls spec validation, content-type selection, and server URL resolution.
133
+ * Configuration for the OpenAPI adapter: spec validation, content-type selection,
134
+ * server URL resolution, and how schema types are derived.
128
135
  *
129
136
  * @example
130
137
  * ```ts
@@ -138,23 +145,28 @@ export type MediaTypeObject = OASMediaTypeObject
138
145
  */
139
146
  export type AdapterOasOptions = {
140
147
  /**
141
- * Validate the OpenAPI spec before parsing.
148
+ * Validate the OpenAPI spec with `@readme/openapi-parser` before parsing.
149
+ * Set to `false` only when you have a known-invalid spec you still want to
150
+ * generate from.
151
+ *
142
152
  * @default true
143
153
  */
144
154
  validate?: boolean
145
155
  /**
146
- * Preferred content-type used when extracting request/response schemas.
147
- * Defaults to the first valid JSON media type found in the spec.
156
+ * Preferred media type when an operation defines several. Defaults to the
157
+ * first JSON-compatible media type found in the spec.
148
158
  */
149
159
  contentType?: ContentType
150
160
  /**
151
- * Index into `oas.api.servers` for computing `baseURL`.
152
- * `0` first server, `1` second server. Omit to leave `baseURL` undefined.
161
+ * Index into the `servers` array from your OpenAPI spec. Used to compute the
162
+ * base URL for plugins that need it. Most projects pick `0` for the primary
163
+ * server. Omit to leave `baseURL` undefined.
153
164
  */
154
165
  serverIndex?: number
155
166
  /**
156
167
  * Override values for `{variable}` placeholders in the selected server URL.
157
- * Only used when `serverIndex` is set.
168
+ * Only used when `serverIndex` is set. Variables you do not provide use
169
+ * their `default` value from the spec.
158
170
  *
159
171
  * @example
160
172
  * ```ts
@@ -165,12 +177,28 @@ export type AdapterOasOptions = {
165
177
  */
166
178
  serverVariables?: Record<string, string>
167
179
  /**
168
- * How the discriminator field is interpreted.
169
- * - `'strict'` uses `oneOf` schemas as written in the spec.
170
- * - `'inherit'` propagates discriminator values into child schemas from `discriminator.mapping`.
180
+ * How the `discriminator` field on `oneOf`/`anyOf` schemas is interpreted.
181
+ * - `'strict'` child schemas stay exactly as written. The discriminator
182
+ * narrows types at the call site but child shapes are not modified.
183
+ * - `'inherit'` Kubb propagates the discriminator property as a literal
184
+ * value into each child schema, so each branch's discriminator field is
185
+ * precisely typed.
186
+ *
171
187
  * @default 'strict'
172
188
  */
173
189
  discriminator?: 'strict' | 'inherit'
190
+ /**
191
+ * Collapse structurally identical schemas and enums into a single shared definition.
192
+ *
193
+ * Duplicated inline shapes (especially enums repeated across many properties) are hoisted
194
+ * into one named schema. Every other occurrence, and any structurally identical top-level
195
+ * component, becomes a `ref` to it. Equality is shape-only, so documentation such as
196
+ * `description` and `example` is ignored. Set to `false` to keep every occurrence inline
197
+ * and produce byte-for-byte identical output to earlier versions.
198
+ *
199
+ * @default true
200
+ */
201
+ dedupe?: boolean
174
202
  } & Partial<ast.ParserOptions>
175
203
 
176
204
  /**
@@ -182,6 +210,7 @@ export type AdapterOasResolvedOptions = {
182
210
  serverIndex: AdapterOasOptions['serverIndex']
183
211
  serverVariables: AdapterOasOptions['serverVariables']
184
212
  discriminator: NonNullable<AdapterOasOptions['discriminator']>
213
+ dedupe: NonNullable<AdapterOasOptions['dedupe']>
185
214
  dateType: NonNullable<AdapterOasOptions['dateType']>
186
215
  integerType: NonNullable<AdapterOasOptions['integerType']>
187
216
  unknownType: NonNullable<AdapterOasOptions['unknownType']>