@kubb/adapter-oas 5.0.0-beta.2 → 5.0.0-beta.21

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/extension.yaml ADDED
@@ -0,0 +1,344 @@
1
+ $schema: https://kubb.dev/schemas/extension.json
2
+ kind: adapter
3
+ id: adapter-oas
4
+ name: OpenAPI
5
+ description: Parse and convert OpenAPI 2.0, 3.0, and 3.1 specifications into Kubb's universal AST with full support for discriminators, date types, and server configuration.
6
+ category: openapi
7
+ type: official
8
+ npmPackage: '@kubb/adapter-oas'
9
+ docsPath: /adapters/adapter-oas
10
+ repo: https://github.com/kubb-labs/kubb
11
+ maintainers:
12
+ - name: Stijn Van Hulle
13
+ github: stijnvanhulle
14
+ compatibility:
15
+ kubb: '>=5.0.0'
16
+ node: '>=22'
17
+ tags:
18
+ - openapi
19
+ - swagger
20
+ - api-spec
21
+ - parser
22
+ - converter
23
+ resources:
24
+ documentation: https://kubb.dev/adapters/adapter-oas
25
+ repository: https://github.com/kubb-labs/kubb
26
+ issues: https://github.com/kubb-labs/kubb/issues
27
+ changelog: https://github.com/kubb-labs/kubb/blob/main/packages/adapter-oas/CHANGELOG.md
28
+ featured: true
29
+ icon:
30
+ light: https://kubb.dev/feature/openapi.svg
31
+ intro: |-
32
+ The OpenAPI adapter parses and converts your OpenAPI specification into Kubb's internal AST (Abstract Syntax Tree), which all downstream plugins consume.
33
+
34
+ The adapter is configured once in `defineConfig` and applies globally to all plugins.
35
+ options:
36
+ - name: validate
37
+ type: boolean
38
+ required: false
39
+ default: 'true'
40
+ description: Validate the OpenAPI spec before parsing using `@readme/openapi-parser`.
41
+ - name: contentType
42
+ type: "'application/json' | string"
43
+ required: false
44
+ description: Preferred content-type used when extracting request/response schemas from the spec. Defaults to the first valid JSON media type found in the spec.
45
+ codeBlock:
46
+ lang: typescript
47
+ title: kubb.config.ts
48
+ twoslash: false
49
+ code: |-
50
+ import { adapterOas } from '@kubb/adapter-oas'
51
+
52
+ adapterOas({ contentType: 'application/vnd.api+json' })
53
+ - name: serverIndex
54
+ type: number
55
+ required: false
56
+ description: Index into the `servers` array from your OpenAPI spec for computing `baseURL`.
57
+ tip: Defining the server here will make it possible to use that endpoint as `baseURL` in other plugins.
58
+ examples:
59
+ - name: OpenAPI
60
+ files:
61
+ - lang: yaml
62
+ code: |-
63
+ openapi: 3.0.3
64
+ servers:
65
+ - url: http://petstore.swagger.io/api
66
+ - url: http://localhost:3000
67
+ - name: serverIndex 0
68
+ files:
69
+ - lang: typescript
70
+ code: |-
71
+ import { adapterOas } from '@kubb/adapter-oas'
72
+
73
+ adapterOas({ serverIndex: 0 })
74
+ - name: serverIndex 1
75
+ files:
76
+ - lang: typescript
77
+ code: |-
78
+ import { adapterOas } from '@kubb/adapter-oas'
79
+
80
+ adapterOas({ serverIndex: 1 })
81
+ - name: serverVariables
82
+ type: Record<string, string>
83
+ required: false
84
+ description: Override values for `{variable}` placeholders in the selected server URL. Only used when `serverIndex` is set. Variables not provided fall back to their `default` value from the spec.
85
+ examples:
86
+ - name: OpenAPI
87
+ files:
88
+ - lang: yaml
89
+ code: |-
90
+ openapi: 3.0.3
91
+ servers:
92
+ - url: https://api.{env}.example.com
93
+ variables:
94
+ env:
95
+ default: dev
96
+ enum: [dev, staging, prod]
97
+ - name: kubb.config.ts
98
+ files:
99
+ - lang: typescript
100
+ code: |-
101
+ import { adapterOas } from '@kubb/adapter-oas'
102
+
103
+ adapterOas({
104
+ serverIndex: 0,
105
+ serverVariables: { env: 'prod' },
106
+ })
107
+ // Results in baseURL: https://api.prod.example.com
108
+ - name: discriminator
109
+ type: "'strict' | 'inherit'"
110
+ required: false
111
+ default: "'strict'"
112
+ description: How the discriminator field is interpreted when processing `oneOf`/`anyOf` schemas.
113
+ details:
114
+ - title: strict
115
+ body: Uses `oneOf` schemas as written in the spec. The discriminator is used for type narrowing but child schemas are not modified.
116
+ - title: inherit
117
+ body: Propagates the discriminator property with appropriate enum values into each child schema, ensuring type safety and enabling better code generation.
118
+ examples:
119
+ - name: OpenAPI
120
+ files:
121
+ - lang: yaml
122
+ code: |-
123
+ openapi: 3.0.3
124
+ components:
125
+ schemas:
126
+ Animal:
127
+ required: [type]
128
+ type: object
129
+ oneOf:
130
+ - $ref: '#/components/schemas/Cat'
131
+ - $ref: '#/components/schemas/Dog'
132
+ discriminator:
133
+ propertyName: type
134
+ mapping:
135
+ cat: '#/components/schemas/Cat'
136
+ dog: '#/components/schemas/Dog'
137
+ Cat:
138
+ type: object
139
+ properties:
140
+ type:
141
+ type: string
142
+ indoor:
143
+ type: boolean
144
+ Dog:
145
+ type: object
146
+ properties:
147
+ type:
148
+ type: string
149
+ name:
150
+ type: string
151
+ - name: strict
152
+ files:
153
+ - lang: typescript
154
+ code: |-
155
+ export type Cat = {
156
+ type: string
157
+ indoor?: boolean
158
+ }
159
+
160
+ export type Dog = {
161
+ type: string
162
+ name?: string
163
+ }
164
+
165
+ export type Animal = Cat | Dog
166
+ - name: inherit
167
+ files:
168
+ - lang: typescript
169
+ code: |-
170
+ export type Cat = {
171
+ type: 'cat'
172
+ indoor?: boolean
173
+ }
174
+
175
+ export type Dog = {
176
+ type: 'dog'
177
+ name?: string
178
+ }
179
+
180
+ export type Animal = Cat | Dog
181
+ - name: dateType
182
+ type: false | 'string' | 'stringOffset' | 'stringLocal' | 'date'
183
+ required: false
184
+ default: "'string'"
185
+ description: "How `format: 'date-time'` schemas are represented in the generated AST and downstream output."
186
+ details:
187
+ - title: 'false'
188
+ body: Falls through to a plain `string` type.
189
+ - title: "'string'"
190
+ body: Emits a datetime string node (e.g. `z.string().datetime()`).
191
+ - title: "'stringOffset'"
192
+ body: 'Emits a datetime node with timezone offset (`{ offset: true }`).'
193
+ - title: "'stringLocal'"
194
+ body: 'Emits a local datetime node (`{ local: true }`).'
195
+ - title: "'date'"
196
+ body: Emits a `date` node (JavaScript `Date` object).
197
+ examples:
198
+ - name: 'false'
199
+ files:
200
+ - lang: typescript
201
+ code: |-
202
+ // format: date-time → plain string
203
+ type CreatedAt = string
204
+ - name: "'string' (default)"
205
+ files:
206
+ - lang: typescript
207
+ code: |-
208
+ // format: date-time → datetime string
209
+ type CreatedAt = string // validated as ISO 8601 datetime
210
+ - name: "'stringOffset'"
211
+ files:
212
+ - lang: typescript
213
+ code: |-
214
+ // format: date-time → datetime string with offset
215
+ type CreatedAt = string // validated as ISO 8601 datetime with offset
216
+ - name: "'stringLocal'"
217
+ files:
218
+ - lang: typescript
219
+ code: |-
220
+ // format: date-time → local datetime string
221
+ type CreatedAt = string // validated as local ISO 8601 datetime
222
+ - name: "'date'"
223
+ files:
224
+ - lang: typescript
225
+ code: |-
226
+ // format: date-time → JavaScript Date
227
+ type CreatedAt = Date
228
+ - name: integerType
229
+ type: "'number' | 'bigint'"
230
+ required: false
231
+ default: "'bigint'"
232
+ description: "Whether `type: 'integer'` and `format: 'int64'` produce `number` or `bigint` nodes."
233
+ examples:
234
+ - name: "'number' (default)"
235
+ files:
236
+ - lang: typescript
237
+ code: |-
238
+ type Pet = {
239
+ id: number
240
+ }
241
+ - name: "'bigint'"
242
+ files:
243
+ - lang: typescript
244
+ code: |-
245
+ type Pet = {
246
+ id: bigint
247
+ }
248
+ - name: unknownType
249
+ type: "'any' | 'unknown' | 'void'"
250
+ required: false
251
+ default: "'any'"
252
+ description: AST type used when no schema type can be inferred from the OpenAPI spec.
253
+ examples:
254
+ - name: "'any' (default)"
255
+ files:
256
+ - lang: typescript
257
+ code: |-
258
+ type Pet = {
259
+ extra: any
260
+ }
261
+ - name: "'unknown'"
262
+ files:
263
+ - lang: typescript
264
+ code: |-
265
+ type Pet = {
266
+ extra: unknown
267
+ }
268
+ - name: "'void'"
269
+ files:
270
+ - lang: typescript
271
+ code: |-
272
+ type Pet = {
273
+ extra: void
274
+ }
275
+ - name: emptySchemaType
276
+ type: "'any' | 'unknown' | 'void'"
277
+ required: false
278
+ default: unknownType | 'any'
279
+ description: AST type used for completely empty schemas (`{}`).
280
+ tip: When not set, `emptySchemaType` falls back to the value of `unknownType`. Set it explicitly only when you want a different type for empty schemas than for unresolvable ones.
281
+ examples:
282
+ - name: "'any' (default)"
283
+ files:
284
+ - lang: typescript
285
+ code: |-
286
+ // empty schema {} → any
287
+ type EmptyModel = any
288
+ - name: "'unknown'"
289
+ files:
290
+ - lang: typescript
291
+ code: |-
292
+ // empty schema {} → unknown
293
+ type EmptyModel = unknown
294
+ - name: enumSuffix
295
+ type: string
296
+ required: false
297
+ default: "'enum'"
298
+ description: Suffix appended to derived enum names when building nested property schema names.
299
+ examples:
300
+ - name: "'enum' (default)"
301
+ files:
302
+ - lang: typescript
303
+ code: |-
304
+ // property `status` with inline enum values
305
+ const statusEnum = { available: 'available', pending: 'pending' } as const
306
+ type StatusEnum = (typeof statusEnum)[keyof typeof statusEnum]
307
+ - name: "'type'"
308
+ files:
309
+ - lang: typescript
310
+ code: |-
311
+ // enumSuffix: 'type'
312
+ const statusType = { available: 'available', pending: 'pending' } as const
313
+ type StatusType = (typeof statusType)[keyof typeof statusType]
314
+ examples:
315
+ - name: kubb.config.ts
316
+ files:
317
+ - lang: typescript
318
+ code: |-
319
+ import { defineConfig } from 'kubb'
320
+ import { adapterOas } from '@kubb/adapter-oas'
321
+ import { pluginTs } from '@kubb/plugin-ts'
322
+
323
+ export default defineConfig({
324
+ input: {
325
+ path: './petStore.yaml',
326
+ },
327
+ output: {
328
+ path: './src/gen',
329
+ },
330
+ adapter: adapterOas({
331
+ validate: true,
332
+ serverIndex: 0,
333
+ serverVariables: { env: 'prod' },
334
+ discriminator: 'inherit',
335
+ dateType: 'date',
336
+ integerType: 'number',
337
+ unknownType: 'unknown',
338
+ emptySchemaType: 'unknown',
339
+ enumSuffix: 'enum',
340
+ }),
341
+ plugins: [
342
+ pluginTs(),
343
+ ],
344
+ })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kubb/adapter-oas",
3
- "version": "5.0.0-beta.2",
4
- "description": "OpenAPI / Swagger adapter for Kubb converts OAS input into a @kubb/ast RootNode.",
3
+ "version": "5.0.0-beta.21",
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",
7
7
  "codegen",
@@ -21,6 +21,7 @@
21
21
  "files": [
22
22
  "src",
23
23
  "dist",
24
+ "extension.yaml",
24
25
  "!/**/**.test.**",
25
26
  "!/**/__tests__/**",
26
27
  "!/**/__snapshots__/**"
@@ -42,11 +43,11 @@
42
43
  "registry": "https://registry.npmjs.org/"
43
44
  },
44
45
  "dependencies": {
45
- "@redocly/openapi-core": "^2.30.3",
46
+ "@redocly/openapi-core": "^2.30.6",
46
47
  "oas": "^32.1.18",
47
48
  "oas-normalize": "^16.0.4",
48
49
  "swagger2openapi": "^7.0.8",
49
- "@kubb/core": "5.0.0-beta.2"
50
+ "@kubb/core": "5.0.0-beta.21"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@types/swagger2openapi": "^7.0.4",
@@ -67,6 +68,7 @@
67
68
  "release": "pnpm publish --no-git-check",
68
69
  "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check",
69
70
  "start": "tsdown --watch",
71
+ "bench": "vitest bench",
70
72
  "test": "vitest --passWithNoTests",
71
73
  "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false"
72
74
  }
@@ -0,0 +1,64 @@
1
+ import path from 'node:path'
2
+ import { existsSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { bench, describe } from 'vitest'
5
+ import { adapterOas } from './adapter.ts'
6
+ import { parseDocument } from './factory.ts'
7
+ import { parseOas } from './parser.ts'
8
+ import type { Document } from './types.ts'
9
+ import type { AdapterSource } from '@kubb/core'
10
+
11
+ const __filename = fileURLToPath(import.meta.url)
12
+ const __dirname = path.dirname(__filename)
13
+
14
+ const petStorePath = path.resolve(__dirname, '../mocks/petStore.yaml')
15
+ const stripeSpecPath = '/tmp/kubb-stripe-spec3.json'
16
+ const hasStripe = existsSync(stripeSpecPath)
17
+
18
+ let petStoreDoc: Document | undefined
19
+
20
+ async function getPetStoreDocument(): Promise<Document> {
21
+ if (!petStoreDoc) {
22
+ petStoreDoc = await parseDocument(petStorePath)
23
+ }
24
+ return petStoreDoc
25
+ }
26
+
27
+ describe('parseOas() performance', () => {
28
+ bench(
29
+ 'petStore spec',
30
+ async () => {
31
+ const doc = await getPetStoreDocument()
32
+ parseOas(doc)
33
+ },
34
+ { iterations: 5, warmupIterations: 1 },
35
+ )
36
+ })
37
+
38
+ describe.skipIf(!hasStripe)('Stripe spec — batch vs streaming (1,385 schemas)', () => {
39
+ const stripeSource: AdapterSource = { type: 'path', path: stripeSpecPath }
40
+
41
+ bench(
42
+ 'batch — adapter.parse()',
43
+ async () => {
44
+ const adapter = adapterOas({ validate: false })
45
+ await adapter.parse(stripeSource)
46
+ },
47
+ { iterations: 3, warmupIterations: 1 },
48
+ )
49
+
50
+ bench(
51
+ 'streaming — adapter.stream() drain',
52
+ async () => {
53
+ const adapter = adapterOas({ validate: false })
54
+ const stream = await adapter.stream!(stripeSource)
55
+ for await (const _ of stream.schemas) {
56
+ /* drain */
57
+ }
58
+ for await (const _ of stream.operations) {
59
+ /* drain */
60
+ }
61
+ },
62
+ { iterations: 3, warmupIterations: 1 },
63
+ )
64
+ })
package/src/adapter.ts CHANGED
@@ -1,9 +1,12 @@
1
+ import { once } from '@internals/utils'
1
2
  import { ast, createAdapter } from '@kubb/core'
3
+ import type { AdapterSource } from '@kubb/core'
4
+ import BaseOas from 'oas'
2
5
  import { DEFAULT_PARSER_OPTIONS } from './constants.ts'
3
- import { applyDiscriminatorInheritance } from './discriminator.ts'
4
- import { parseFromConfig, validateDocument } from './factory.ts'
5
- import { parseOas } from './parser.ts'
6
- import { resolveServerUrl } from './resolvers.ts'
6
+ import { parseDocument, parseFromConfig, validateDocument } from './factory.ts'
7
+ import { createSchemaParser } from './parser.ts'
8
+ import { getSchemas } from './resolvers.ts'
9
+ import { createInputStream, preScan, resolveBaseUrl } from './stream.ts'
7
10
  import type { AdapterOas, Document } from './types.ts'
8
11
 
9
12
  /**
@@ -44,10 +47,64 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
44
47
  emptySchemaType = unknownType || DEFAULT_PARSER_OPTIONS.emptySchemaType,
45
48
  } = options
46
49
 
47
- // Let-binding so parse() can replace it with a simple reassignment (no clear+loop).
50
+ const parserOptions: ast.ParserOptions = {
51
+ ...DEFAULT_PARSER_OPTIONS,
52
+ dateType,
53
+ integerType,
54
+ unknownType,
55
+ emptySchemaType,
56
+ enumSuffix,
57
+ }
58
+
48
59
  let nameMapping = new Map<string, string>()
49
- let parsedDocument: Document | null
50
- let inputNode: ast.InputNode | null
60
+ let parsedDocument: Document | null = null
61
+
62
+ // `once` collapses concurrent callers (e.g. a build's `stream()` racing with `openInStudio()`'s `parse()`) onto one in-flight promise.
63
+ const ensureDocument = once(async (source: AdapterSource): Promise<Document> => {
64
+ const fresh = await parseFromConfig(source)
65
+ if (validate) await validateDocument(fresh)
66
+ parsedDocument = fresh
67
+ return fresh
68
+ })
69
+
70
+ const ensureSchemas = once(async (document: Document) => {
71
+ const result = getSchemas(document, { contentType })
72
+ nameMapping = result.nameMapping
73
+ return result.schemas
74
+ })
75
+
76
+ const ensureBaseOas = once((document: Document) => new BaseOas(document))
77
+
78
+ const ensureSchemaParser = once((document: Document) => createSchemaParser({ document, contentType }))
79
+
80
+ const ensurePreScan = once((schemas: Awaited<ReturnType<typeof ensureSchemas>>, parseSchema: ReturnType<typeof ensureSchemaParser>['parseSchema']) =>
81
+ preScan({ schemas, parseSchema, parserOptions, discriminator }),
82
+ )
83
+
84
+ async function createStream(source: AdapterSource): Promise<ast.InputStreamNode> {
85
+ const document = await ensureDocument(source)
86
+ const schemas = await ensureSchemas(document)
87
+ const { parseSchema, parseOperation } = ensureSchemaParser(document)
88
+ const { refAliasMap, enumNames, circularNames, discriminatorChildMap } = ensurePreScan(schemas, parseSchema)
89
+
90
+ return createInputStream({
91
+ schemas,
92
+ parseSchema,
93
+ parseOperation,
94
+ baseOas: ensureBaseOas(document),
95
+ parserOptions,
96
+ refAliasMap,
97
+ discriminatorChildMap,
98
+ meta: {
99
+ title: document.info?.title,
100
+ description: document.info?.description,
101
+ version: document.info?.version,
102
+ baseURL: resolveBaseUrl({ document, serverIndex, serverVariables }),
103
+ circularNames,
104
+ enumNames,
105
+ },
106
+ })
107
+ }
51
108
 
52
109
  return {
53
110
  name: adapterOasName,
@@ -69,8 +126,9 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
69
126
  get document() {
70
127
  return parsedDocument
71
128
  },
72
- get inputNode() {
73
- return inputNode
129
+ async validate(input, options) {
130
+ const document = await parseDocument(input)
131
+ await validateDocument(document, options)
74
132
  },
75
133
  getImports(node, resolve) {
76
134
  return ast.collectImports({
@@ -78,49 +136,25 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
78
136
  nameMapping,
79
137
  resolve: (schemaName) => {
80
138
  const result = resolve(schemaName)
81
- if (!result) return
139
+ if (!result) return null
82
140
 
83
141
  return ast.createImport({ name: [result.name], path: result.path })
84
142
  },
85
143
  })
86
144
  },
87
145
  async parse(source) {
88
- const document = await parseFromConfig(source)
146
+ const streamNode = await createStream(source)
89
147
 
90
- if (validate) {
91
- await validateDocument(document)
148
+ const collect = async <T>(iter: AsyncIterable<T>): Promise<T[]> => {
149
+ const out: T[] = []
150
+ for await (const item of iter) out.push(item)
151
+ return out
92
152
  }
93
153
 
94
- const server = serverIndex !== undefined ? document.servers?.at(serverIndex) : undefined
95
- const baseURL = server?.url ? resolveServerUrl(server, serverVariables) : undefined
96
-
97
- const { root: parsedRoot, nameMapping: parsedNameMapping } = parseOas(document, {
98
- contentType,
99
- dateType,
100
- integerType,
101
- unknownType,
102
- emptySchemaType,
103
- enumSuffix,
104
- })
105
-
106
- const node = discriminator === 'inherit' ? applyDiscriminatorInheritance(parsedRoot) : parsedRoot
107
-
108
- // This must happen after parseOas() because legacy enum remapping is finalized there.
109
- nameMapping = parsedNameMapping
110
- // Expose the raw document so consumers (e.g. plugin-redoc) can access it.
111
- parsedDocument = document
112
-
113
- inputNode = ast.createInput({
114
- ...node,
115
- meta: {
116
- title: document.info?.title,
117
- description: document.info?.description,
118
- version: document.info?.version,
119
- baseURL,
120
- },
121
- })
154
+ const [schemas, operations] = await Promise.all([collect(streamNode.schemas), collect(streamNode.operations)])
122
155
 
123
- return inputNode
156
+ return ast.createInput({ schemas, operations, meta: streamNode.meta })
124
157
  },
158
+ stream: createStream,
125
159
  }
126
160
  })