@kubb/adapter-oas 5.0.0-beta.30 → 5.0.0-beta.31

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.30",
3
+ "version": "5.0.0-beta.31",
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",
@@ -43,11 +43,11 @@
43
43
  "registry": "https://registry.npmjs.org/"
44
44
  },
45
45
  "dependencies": {
46
- "@redocly/openapi-core": "^2.31.2",
46
+ "@redocly/openapi-core": "^2.31.4",
47
47
  "oas": "^32.1.18",
48
48
  "oas-normalize": "^16.0.4",
49
49
  "swagger2openapi": "^7.0.8",
50
- "@kubb/core": "5.0.0-beta.30"
50
+ "@kubb/core": "5.0.0-beta.31"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/swagger2openapi": "^7.0.4",
package/src/adapter.ts CHANGED
@@ -48,6 +48,7 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
48
48
  serverIndex,
49
49
  serverVariables,
50
50
  discriminator = 'strict',
51
+ dedupe = true,
51
52
  dateType = DEFAULT_PARSER_OPTIONS.dateType,
52
53
  integerType = DEFAULT_PARSER_OPTIONS.integerType,
53
54
  unknownType = DEFAULT_PARSER_OPTIONS.unknownType,
@@ -85,24 +86,31 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
85
86
 
86
87
  const ensureSchemaParser = once((document: Document) => createSchemaParser({ document, contentType }))
87
88
 
88
- const ensurePreScan = once((schemas: Awaited<ReturnType<typeof ensureSchemas>>, parseSchema: ReturnType<typeof ensureSchemaParser>['parseSchema']) =>
89
- preScan({ schemas, parseSchema, parserOptions, discriminator }),
89
+ const ensurePreScan = once(
90
+ (
91
+ schemas: Awaited<ReturnType<typeof ensureSchemas>>,
92
+ parseSchema: ReturnType<typeof ensureSchemaParser>['parseSchema'],
93
+ parseOperation: ReturnType<typeof ensureSchemaParser>['parseOperation'],
94
+ baseOas: ReturnType<typeof ensureBaseOas>,
95
+ ) => preScan({ schemas, parseSchema, parseOperation, baseOas, parserOptions, discriminator, dedupe }),
90
96
  )
91
97
 
92
98
  async function createStream(source: AdapterSource): Promise<ast.InputStreamNode> {
93
99
  const document = await ensureDocument(source)
94
100
  const schemas = await ensureSchemas(document)
95
101
  const { parseSchema, parseOperation } = ensureSchemaParser(document)
96
- const { refAliasMap, enumNames, circularNames, discriminatorChildMap } = ensurePreScan(schemas, parseSchema)
102
+ const baseOas = ensureBaseOas(document)
103
+ const { refAliasMap, enumNames, circularNames, discriminatorChildMap, dedupePlan } = ensurePreScan(schemas, parseSchema, parseOperation, baseOas)
97
104
 
98
105
  return createInputStream({
99
106
  schemas,
100
107
  parseSchema,
101
108
  parseOperation,
102
- baseOas: ensureBaseOas(document),
109
+ baseOas,
103
110
  parserOptions,
104
111
  refAliasMap,
105
112
  discriminatorChildMap,
113
+ dedupePlan,
106
114
  meta: {
107
115
  title: document.info?.title,
108
116
  description: document.info?.description,
@@ -123,6 +131,7 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
123
131
  serverIndex,
124
132
  serverVariables,
125
133
  discriminator,
134
+ dedupe,
126
135
  dateType,
127
136
  integerType,
128
137
  unknownType,
package/src/parser.ts CHANGED
@@ -494,6 +494,22 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
494
494
 
495
495
  const nullInEnum = schema.enum!.includes(null)
496
496
  const filteredValues = (nullInEnum ? schema.enum!.filter((v) => v !== null) : schema.enum!) as Array<string | number | boolean>
497
+
498
+ // drf-spectacular `NullEnum` ({ enum: [null] }) is just `null`; an empty enum node would
499
+ // render as `never` (plugin-ts) / invalid `z.enum([])` (plugin-zod). Mirror the `const: null`
500
+ // branch so it renders as a clean `null` (not `z.null().nullable()`).
501
+ if (nullInEnum && filteredValues.length === 0) {
502
+ return ast.createSchema({
503
+ type: 'null',
504
+ primitive: 'null',
505
+ name,
506
+ title: schema.title,
507
+ description: schema.description,
508
+ deprecated: schema.deprecated,
509
+ format: schema.format,
510
+ })
511
+ }
512
+
497
513
  const enumNullable = nullable || nullInEnum || undefined
498
514
  const enumDefault = schema.default === null && enumNullable ? undefined : schema.default
499
515
  const enumPrimitive = getPrimitiveType(type)
package/src/stream.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ast } from '@kubb/core'
2
2
  import type BaseOas from 'oas'
3
+ import { SCHEMA_REF_PREFIX } from './constants.ts'
3
4
  import { buildDiscriminatorChildMap, patchDiscriminatorNode } from './discriminator.ts'
4
5
  import type { SchemaParser } from './parser.ts'
5
6
  import { resolveServerUrl } from './resolvers.ts'
@@ -11,6 +12,53 @@ export type PreScanResult = {
11
12
  enumNames: Array<string>
12
13
  circularNames: Array<string>
13
14
  discriminatorChildMap: Map<string, DiscriminatorTarget> | null
15
+ dedupePlan: ast.DedupePlan | null
16
+ }
17
+
18
+ /**
19
+ * Builds the deduplication plan from the already-parsed top-level schema nodes plus a
20
+ * single extra parse pass over operations (so duplicates in request/response bodies are seen).
21
+ *
22
+ * Only enums and objects are candidates, and object shapes that reference a circular schema are
23
+ * rejected to avoid hoisting recursive structures. Inline shapes reuse their context-derived
24
+ * name (collision-resolved against existing component names); shapes without a name stay inline.
25
+ */
26
+ function createDedupePlan({
27
+ schemaNodes,
28
+ operationNodes,
29
+ schemaNames,
30
+ circularNames,
31
+ }: {
32
+ schemaNodes: Array<ast.SchemaNode>
33
+ operationNodes: Array<ast.OperationNode>
34
+ schemaNames: Array<string>
35
+ circularNames: Array<string>
36
+ }): ast.DedupePlan {
37
+ const circularSchemas = new Set(circularNames)
38
+ const usedNames = new Set(schemaNames)
39
+
40
+ return ast.buildDedupePlan([...schemaNodes, ...operationNodes], {
41
+ isCandidate: (node) => {
42
+ if (node.type === 'enum') return true
43
+ if (node.type !== 'object') return false
44
+ // Skip object shapes that are part of a circular chain — hoisting them would break the cycle.
45
+ if (node.name && circularSchemas.has(node.name)) return false
46
+ return !ast.containsCircularRef(node, { circularSchemas })
47
+ },
48
+ nameFor: (node) => {
49
+ const base = node.name
50
+ if (!base) return null
51
+
52
+ let name = base
53
+ let counter = 2
54
+ while (usedNames.has(name)) {
55
+ name = `${base}${counter++}`
56
+ }
57
+ usedNames.add(name)
58
+ return name
59
+ },
60
+ refFor: (name) => `${SCHEMA_REF_PREFIX}${name}`,
61
+ })
14
62
  }
15
63
 
16
64
  /**
@@ -66,13 +114,19 @@ export function resolveBaseUrl({
66
114
  export function preScan({
67
115
  schemas,
68
116
  parseSchema,
117
+ parseOperation,
118
+ baseOas,
69
119
  parserOptions,
70
120
  discriminator,
121
+ dedupe,
71
122
  }: {
72
123
  schemas: Record<string, SchemaObject>
73
124
  parseSchema: (entry: { schema: SchemaObject; name: string }, options: ast.ParserOptions) => ast.SchemaNode
125
+ parseOperation: SchemaParser['parseOperation']
126
+ baseOas: BaseOas
74
127
  parserOptions: ast.ParserOptions
75
128
  discriminator: AdapterOas['options']['discriminator']
129
+ dedupe: boolean
76
130
  }): PreScanResult {
77
131
  const allNodes: Array<ast.SchemaNode> = []
78
132
  const refAliasMap = new Map<string, ast.SchemaNode>()
@@ -96,7 +150,27 @@ export function preScan({
96
150
  const circularNames = [...ast.findCircularSchemas(allNodes)]
97
151
  const discriminatorChildMap = discriminatorParentNodes.length > 0 ? buildDiscriminatorChildMap(discriminatorParentNodes) : null
98
152
 
99
- return { refAliasMap, enumNames, circularNames, discriminatorChildMap }
153
+ let dedupePlan: ast.DedupePlan | null = null
154
+ if (dedupe) {
155
+ // One extra parse pass over operations so duplicates in request/response bodies are seen.
156
+ // Reuses the already-parsed `allNodes` for schemas — no second schema parse.
157
+ const operationNodes: Array<ast.OperationNode> = []
158
+ for (const methods of Object.values(baseOas.getPaths())) {
159
+ for (const operation of Object.values(methods)) {
160
+ if (!operation) continue
161
+ const operationNode = parseOperation(parserOptions, operation)
162
+ if (operationNode) operationNodes.push(operationNode)
163
+ }
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
+ return { refAliasMap, enumNames, circularNames, discriminatorChildMap, dedupePlan }
100
174
  }
101
175
 
102
176
  /**
@@ -125,6 +199,7 @@ export function createInputStream({
125
199
  parserOptions,
126
200
  refAliasMap,
127
201
  discriminatorChildMap,
202
+ dedupePlan,
128
203
  meta,
129
204
  }: {
130
205
  schemas: Record<string, SchemaObject>
@@ -134,24 +209,50 @@ export function createInputStream({
134
209
  parserOptions: ast.ParserOptions
135
210
  refAliasMap: Map<string, ast.SchemaNode>
136
211
  discriminatorChildMap: Map<string, DiscriminatorTarget> | null
212
+ dedupePlan: ast.DedupePlan | null
137
213
  meta: ast.InputMeta
138
214
  }): ast.InputStreamNode {
215
+ // Rewrites a top-level schema against the dedupe plan: a structurally identical sibling
216
+ // becomes a `ref` alias to the canonical one (keeping its own name); otherwise nested
217
+ // duplicates are collapsed while the schema's own root is preserved.
218
+ const rewriteTopLevelSchema = (node: ast.SchemaNode): ast.SchemaNode => {
219
+ if (!dedupePlan) return node
220
+
221
+ const canonical = dedupePlan.canonicalBySignature.get(ast.schemaSignature(node))
222
+ if (canonical && canonical.name !== node.name) {
223
+ return ast.createSchema({
224
+ type: 'ref',
225
+ name: node.name ?? null,
226
+ ref: canonical.ref,
227
+ description: node.description,
228
+ deprecated: node.deprecated,
229
+ })
230
+ }
231
+
232
+ return ast.applyDedupe(node, dedupePlan.canonicalBySignature, true)
233
+ }
234
+
139
235
  const schemasIterable: AsyncIterable<ast.SchemaNode> = {
140
236
  [Symbol.asyncIterator]() {
141
237
  return (async function* () {
238
+ // Hoisted canonical definitions are emitted first so the schema list owns the shared shapes.
239
+ if (dedupePlan) {
240
+ for (const definition of dedupePlan.hoisted) yield definition
241
+ }
242
+
142
243
  for (const [name, schema] of Object.entries(schemas)) {
143
244
  // Inline ref aliases: replace the alias entry with its target's parsed node
144
245
  // (keeping the alias name). Skip the first parse entirely for alias entries
145
246
  // since that result is never used.
146
247
  const alias = refAliasMap.get(name)
147
248
  if (alias?.name && schemas[alias.name]) {
148
- yield { ...parseSchema({ schema: schemas[alias.name]!, name: alias.name }, parserOptions), name }
249
+ yield rewriteTopLevelSchema({ ...parseSchema({ schema: schemas[alias.name]!, name: alias.name }, parserOptions), name })
149
250
  continue
150
251
  }
151
252
 
152
253
  const parsed = parseSchema({ schema, name }, parserOptions)
153
254
  const node = discriminatorChildMap?.get(name) ? patchDiscriminatorNode(parsed, discriminatorChildMap.get(name)!) : parsed
154
- yield node
255
+ yield rewriteTopLevelSchema(node)
155
256
  }
156
257
  })()
157
258
  },
@@ -164,7 +265,7 @@ export function createInputStream({
164
265
  for (const operation of Object.values(methods)) {
165
266
  if (!operation) continue
166
267
  const node = parseOperation(parserOptions, operation)
167
- if (node) yield node
268
+ if (node) yield dedupePlan ? ast.applyDedupe(node, dedupePlan.canonicalBySignature) : node
168
269
  }
169
270
  }
170
271
  })()
package/src/types.ts CHANGED
@@ -181,6 +181,18 @@ export type AdapterOasOptions = {
181
181
  * @default 'strict'
182
182
  */
183
183
  discriminator?: 'strict' | 'inherit'
184
+ /**
185
+ * Collapse structurally identical schemas and enums into a single shared definition.
186
+ *
187
+ * Duplicated inline shapes (especially enums repeated across many properties) are hoisted
188
+ * into one named schema; every other occurrence — and any structurally identical top-level
189
+ * component — becomes a `ref` to it. Equality is shape-only: documentation such as
190
+ * `description` and `example` is ignored. Enabled by default; set to `false` to keep every
191
+ * occurrence inline and produce byte-for-byte identical output to earlier versions.
192
+ *
193
+ * @default true
194
+ */
195
+ dedupe?: boolean
184
196
  } & Partial<ast.ParserOptions>
185
197
 
186
198
  /**
@@ -192,6 +204,7 @@ export type AdapterOasResolvedOptions = {
192
204
  serverIndex: AdapterOasOptions['serverIndex']
193
205
  serverVariables: AdapterOasOptions['serverVariables']
194
206
  discriminator: NonNullable<AdapterOasOptions['discriminator']>
207
+ dedupe: NonNullable<AdapterOasOptions['dedupe']>
195
208
  dateType: NonNullable<AdapterOasOptions['dateType']>
196
209
  integerType: NonNullable<AdapterOasOptions['integerType']>
197
210
  unknownType: NonNullable<AdapterOasOptions['unknownType']>