@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kubb/adapter-oas",
3
- "version": "5.0.0-beta.6",
4
- "description": "OpenAPI / Swagger adapter for Kubb converts OAS input into a @kubb/ast RootNode.",
3
+ "version": "5.0.0-beta.60",
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,7 +21,6 @@
21
21
  "files": [
22
22
  "src",
23
23
  "dist",
24
- "extension.yaml",
25
24
  "!/**/**.test.**",
26
25
  "!/**/__tests__/**",
27
26
  "!/**/__snapshots__/**"
@@ -43,13 +42,15 @@
43
42
  "registry": "https://registry.npmjs.org/"
44
43
  },
45
44
  "dependencies": {
46
- "@redocly/openapi-core": "^2.30.4",
47
- "oas": "^32.1.18",
48
- "oas-normalize": "^16.0.4",
45
+ "@readme/openapi-parser": "^6.1.3",
46
+ "api-ref-bundler": "0.5.1",
49
47
  "swagger2openapi": "^7.0.8",
50
- "@kubb/core": "5.0.0-beta.6"
48
+ "yaml": "^2.9.0",
49
+ "@kubb/ast": "5.0.0-beta.60",
50
+ "@kubb/core": "5.0.0-beta.60"
51
51
  },
52
52
  "devDependencies": {
53
+ "@types/json-schema": "^7.0.15",
53
54
  "@types/swagger2openapi": "^7.0.4",
54
55
  "openapi-types": "^12.1.3",
55
56
  "@internals/utils": "0.0.0"
@@ -58,15 +59,18 @@
58
59
  "node": ">=22"
59
60
  },
60
61
  "inlinedDependencies": {
62
+ "@types/json-schema": "7.0.15",
61
63
  "openapi-types": "12.1.3"
62
64
  },
63
65
  "scripts": {
66
+ "bench": "vitest bench",
64
67
  "build": "tsdown",
65
- "clean": "npx rimraf ./dist",
68
+ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"",
66
69
  "lint": "oxlint .",
67
70
  "lint:fix": "oxlint --fix .",
68
71
  "release": "pnpm publish --no-git-check",
69
72
  "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check",
73
+ "release:stage": "pnpm stage publish --no-git-check",
70
74
  "start": "tsdown --watch",
71
75
  "test": "vitest --passWithNoTests",
72
76
  "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false"
@@ -0,0 +1,60 @@
1
+ import path from 'node:path'
2
+ import { existsSync } from 'node:fs'
3
+ import { bench, describe } from 'vitest'
4
+ import { adapterOas } from './adapter.ts'
5
+ import { parseDocument } from './factory.ts'
6
+ import { parseOas } from './parser.ts'
7
+ import type { Document } from './types.ts'
8
+ import type { AdapterSource } from '@kubb/core'
9
+
10
+ const petStorePath = path.resolve(import.meta.dirname, '../mocks/petStore.yaml')
11
+ const stripeSpecPath = '/tmp/kubb-stripe-spec3.json'
12
+ const hasStripe = existsSync(stripeSpecPath)
13
+
14
+ let petStoreDoc: Document | undefined
15
+
16
+ async function getPetStoreDocument(): Promise<Document> {
17
+ if (!petStoreDoc) {
18
+ petStoreDoc = await parseDocument(petStorePath)
19
+ }
20
+ return petStoreDoc
21
+ }
22
+
23
+ describe('parseOas() performance', () => {
24
+ bench(
25
+ 'petStore spec',
26
+ async () => {
27
+ const doc = await getPetStoreDocument()
28
+ parseOas(doc)
29
+ },
30
+ { iterations: 5, warmupIterations: 1 },
31
+ )
32
+ })
33
+
34
+ describe.skipIf(!hasStripe)('Stripe spec — batch vs streaming (1,385 schemas)', () => {
35
+ const stripeSource: AdapterSource = { type: 'path', path: stripeSpecPath }
36
+
37
+ bench(
38
+ 'batch — adapter.parse()',
39
+ async () => {
40
+ const adapter = adapterOas({ validate: false })
41
+ await adapter.parse(stripeSource)
42
+ },
43
+ { iterations: 3, warmupIterations: 1 },
44
+ )
45
+
46
+ bench(
47
+ 'streaming — adapter.stream() drain',
48
+ async () => {
49
+ const adapter = adapterOas({ validate: false })
50
+ const stream = await adapter.stream!(stripeSource)
51
+ for await (const _ of stream.schemas) {
52
+ /* drain */
53
+ }
54
+ for await (const _ of stream.operations) {
55
+ /* drain */
56
+ }
57
+ },
58
+ { iterations: 3, warmupIterations: 1 },
59
+ )
60
+ })
package/src/adapter.ts CHANGED
@@ -1,21 +1,27 @@
1
1
  import { ast, createAdapter } from '@kubb/core'
2
+ import type { AdapterSource } from '@kubb/core'
2
3
  import { DEFAULT_PARSER_OPTIONS } from './constants.ts'
3
- import { applyDiscriminatorInheritance } from './discriminator.ts'
4
- import { parseDocument, parseFromConfig, validateDocument } from './factory.ts'
5
- import { parseOas } from './parser.ts'
6
- import { resolveServerUrl } from './resolvers.ts'
4
+ import { assertInputExists, parseDocument, parseFromConfig, validateDocument } from './factory.ts'
5
+ import { createSchemaParser } from './parser.ts'
6
+ import { getSchemas } from './resolvers.ts'
7
+ import { createInputStream, preScan, resolveBaseUrl } from './stream.ts'
7
8
  import type { AdapterOas, Document } from './types.ts'
9
+ import { collect, narrowSchema } from '@kubb/ast'
10
+ import { extractRefName } from '@kubb/ast/utils'
8
11
 
9
12
  /**
10
- * Stable string identifier for the OAS adapter used in Kubb's adapter registry.
13
+ * Canonical adapter name for `@kubb/adapter-oas`. Used for driver lookups.
11
14
  */
12
15
  export const adapterOasName = 'oas' satisfies AdapterOas['name']
13
16
 
14
17
  /**
15
- * Creates the default OpenAPI / Swagger adapter for Kubb.
18
+ * Default Kubb adapter for OpenAPI 2.0, 3.0, and 3.1 specifications. Reads the
19
+ * file at `input.path`, validates it, resolves the base URL, and converts every
20
+ * schema and operation into the universal AST that every downstream plugin
21
+ * consumes.
16
22
  *
17
- * Parses the spec, optionally validates it, resolves the base URL, and converts
18
- * everything into an `InputNode` that downstream plugins consume.
23
+ * Configure once on `defineConfig`. The adapter's choices (date representation,
24
+ * integer width, server URL) apply to every plugin in the build.
19
25
  *
20
26
  * @example
21
27
  * ```ts
@@ -24,8 +30,13 @@ export const adapterOasName = 'oas' satisfies AdapterOas['name']
24
30
  * import { pluginTs } from '@kubb/plugin-ts'
25
31
  *
26
32
  * export default defineConfig({
27
- * adapter: adapterOas({ dateType: 'date', serverIndex: 0 }),
28
- * input: { path: './openapi.yaml' },
33
+ * input: { path: './petStore.yaml' },
34
+ * output: { path: './src/gen' },
35
+ * adapter: adapterOas({
36
+ * serverIndex: 0,
37
+ * discriminator: 'inherit',
38
+ * dateType: 'date',
39
+ * }),
29
40
  * plugins: [pluginTs()],
30
41
  * })
31
42
  * ```
@@ -37,6 +48,7 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
37
48
  serverIndex,
38
49
  serverVariables,
39
50
  discriminator = 'strict',
51
+ dedupe = true,
40
52
  dateType = DEFAULT_PARSER_OPTIONS.dateType,
41
53
  integerType = DEFAULT_PARSER_OPTIONS.integerType,
42
54
  unknownType = DEFAULT_PARSER_OPTIONS.unknownType,
@@ -44,10 +56,103 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
44
56
  emptySchemaType = unknownType || DEFAULT_PARSER_OPTIONS.emptySchemaType,
45
57
  } = options
46
58
 
47
- // Let-binding so parse() can replace it with a simple reassignment (no clear+loop).
59
+ const parserOptions: ast.ParserOptions = {
60
+ ...DEFAULT_PARSER_OPTIONS,
61
+ dateType,
62
+ integerType,
63
+ unknownType,
64
+ emptySchemaType,
65
+ enumSuffix,
66
+ }
67
+
48
68
  let nameMapping = new Map<string, string>()
49
- let parsedDocument: Document | null
50
- let inputNode: ast.InputNode | null
69
+ let parsedDocument: Document | null = null
70
+
71
+ // Cache per source and per document so one adapter instance reused across a `defineConfig` array
72
+ // parses each config's spec instead of replaying the first one. Keying the document by its source
73
+ // object still collapses a config's concurrent `stream()` (build) and `parse()` (studio) calls,
74
+ // which share one source object, onto a single parse. The document-derived caches key off the
75
+ // resulting document, so distinct configs (distinct documents) stay isolated.
76
+ const documentCache = new WeakMap<AdapterSource, Promise<Document>>()
77
+ const schemasCache = new WeakMap<Document, Promise<ReturnType<typeof getSchemas>['schemas']>>()
78
+ const schemaParserCache = new WeakMap<Document, ReturnType<typeof createSchemaParser>>()
79
+ const preScanCache = new WeakMap<Document, ReturnType<typeof preScan>>()
80
+
81
+ function ensureDocument(source: AdapterSource): Promise<Document> {
82
+ const cached = documentCache.get(source)
83
+ if (cached) return cached
84
+
85
+ const promise = (async () => {
86
+ const fresh = await parseFromConfig(source)
87
+ if (validate) await validateDocument(fresh)
88
+ parsedDocument = fresh
89
+ return fresh
90
+ })()
91
+ documentCache.set(source, promise)
92
+ return promise
93
+ }
94
+
95
+ function ensureSchemas(document: Document): Promise<ReturnType<typeof getSchemas>['schemas']> {
96
+ const cached = schemasCache.get(document)
97
+ if (cached) return cached
98
+
99
+ const promise = Promise.resolve().then(() => {
100
+ const result = getSchemas(document, { contentType })
101
+ nameMapping = result.nameMapping
102
+ return result.schemas
103
+ })
104
+ schemasCache.set(document, promise)
105
+ return promise
106
+ }
107
+
108
+ function ensureSchemaParser(document: Document): ReturnType<typeof createSchemaParser> {
109
+ const cached = schemaParserCache.get(document)
110
+ if (cached) return cached
111
+
112
+ const parser = createSchemaParser({ document, contentType })
113
+ schemaParserCache.set(document, parser)
114
+ return parser
115
+ }
116
+
117
+ function ensurePreScan(
118
+ document: Document,
119
+ schemas: ReturnType<typeof getSchemas>['schemas'],
120
+ parseSchema: ReturnType<typeof ensureSchemaParser>['parseSchema'],
121
+ parseOperation: ReturnType<typeof ensureSchemaParser>['parseOperation'],
122
+ ): ReturnType<typeof preScan> {
123
+ const cached = preScanCache.get(document)
124
+ if (cached) return cached
125
+
126
+ const result = preScan({ schemas, parseSchema, parseOperation, document, parserOptions, discriminator, dedupe })
127
+ preScanCache.set(document, result)
128
+ return result
129
+ }
130
+
131
+ async function createStream(source: AdapterSource): Promise<ast.InputNode<true>> {
132
+ const document = await ensureDocument(source)
133
+ const schemas = await ensureSchemas(document)
134
+ const { parseSchema, parseOperation } = ensureSchemaParser(document)
135
+ const { refAliasMap, enumNames, circularNames, discriminatorChildMap, dedupePlan } = ensurePreScan(document, schemas, parseSchema, parseOperation)
136
+
137
+ return createInputStream({
138
+ schemas,
139
+ parseSchema,
140
+ parseOperation,
141
+ document,
142
+ parserOptions,
143
+ refAliasMap,
144
+ discriminatorChildMap,
145
+ dedupePlan,
146
+ meta: {
147
+ title: document.info?.title,
148
+ description: document.info?.description,
149
+ version: document.info?.version,
150
+ baseURL: resolveBaseUrl({ document, serverIndex, serverVariables }),
151
+ circularNames,
152
+ enumNames,
153
+ },
154
+ })
155
+ }
51
156
 
52
157
  return {
53
158
  name: adapterOasName,
@@ -58,6 +163,7 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
58
163
  serverIndex,
59
164
  serverVariables,
60
165
  discriminator,
166
+ dedupe,
61
167
  dateType,
62
168
  integerType,
63
169
  unknownType,
@@ -69,62 +175,33 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
69
175
  get document() {
70
176
  return parsedDocument
71
177
  },
72
- get inputNode() {
73
- return inputNode
74
- },
75
178
  async validate(input, options) {
179
+ await assertInputExists(input)
76
180
  const document = await parseDocument(input)
77
181
  await validateDocument(document, options)
78
182
  },
79
183
  getImports(node, resolve) {
80
- return ast.collectImports({
81
- node,
82
- nameMapping,
83
- resolve: (schemaName) => {
184
+ return collect(node, {
185
+ schema(schemaNode) {
186
+ const schemaRef = narrowSchema(schemaNode, 'ref')
187
+ if (!schemaRef?.ref) return null
188
+
189
+ const rawName = extractRefName(schemaRef.ref)
190
+ const schemaName = nameMapping.get(rawName) ?? rawName
84
191
  const result = resolve(schemaName)
85
- if (!result) return
192
+ if (!result) return null
86
193
 
87
- return ast.createImport({ name: [result.name], path: result.path })
194
+ return ast.factory.createImport({ name: [result.name], path: result.path })
88
195
  },
89
196
  })
90
197
  },
91
198
  async parse(source) {
92
- const document = await parseFromConfig(source)
93
-
94
- if (validate) {
95
- await validateDocument(document)
96
- }
97
-
98
- const server = serverIndex !== undefined ? document.servers?.at(serverIndex) : undefined
99
- const baseURL = server?.url ? resolveServerUrl(server, serverVariables) : undefined
100
-
101
- const { root: parsedRoot, nameMapping: parsedNameMapping } = parseOas(document, {
102
- contentType,
103
- dateType,
104
- integerType,
105
- unknownType,
106
- emptySchemaType,
107
- enumSuffix,
108
- })
199
+ const streamNode = await createStream(source)
109
200
 
110
- const node = discriminator === 'inherit' ? applyDiscriminatorInheritance(parsedRoot) : parsedRoot
111
-
112
- // This must happen after parseOas() because legacy enum remapping is finalized there.
113
- nameMapping = parsedNameMapping
114
- // Expose the raw document so consumers (e.g. plugin-redoc) can access it.
115
- parsedDocument = document
116
-
117
- inputNode = ast.createInput({
118
- ...node,
119
- meta: {
120
- title: document.info?.title,
121
- description: document.info?.description,
122
- version: document.info?.version,
123
- baseURL,
124
- },
125
- })
201
+ const [schemas, operations] = await Promise.all([Array.fromAsync(streamNode.schemas), Array.fromAsync(streamNode.operations)])
126
202
 
127
- return inputNode
203
+ return ast.factory.createInput({ schemas, operations, meta: streamNode.meta })
128
204
  },
205
+ stream: createStream,
129
206
  }
130
207
  })
package/src/bundler.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { read } from '@internals/utils'
2
+ import { bundle } from 'api-ref-bundler'
3
+ import { parse } from 'yaml'
4
+ import type { Document } from './types.ts'
5
+
6
+ const urlRegExp = /^https?:\/+/i
7
+
8
+ async function readSource(sourcePath: string): Promise<string> {
9
+ if (urlRegExp.test(sourcePath)) {
10
+ // api-ref-bundler joins relative refs with posix normalization, collapsing `https://` to
11
+ // `https:/`. The WHATWG URL parser restores the double slash.
12
+ const url = new URL(sourcePath)
13
+ const response = await fetch(url)
14
+
15
+ if (!response.ok) {
16
+ throw new Error(`Cannot fetch the OAS document at ${url.href} (HTTP ${response.status})`)
17
+ }
18
+
19
+ return response.text()
20
+ }
21
+
22
+ return read(sourcePath)
23
+ }
24
+
25
+ async function resolveSource(sourcePath: string): Promise<object | string> {
26
+ const data = await readSource(sourcePath)
27
+
28
+ if (sourcePath.toLowerCase().endsWith('.md')) {
29
+ return data
30
+ }
31
+
32
+ return parse(data) as object
33
+ }
34
+
35
+ /**
36
+ * Bundles a multi-file OpenAPI document into a single document via `api-ref-bundler`.
37
+ *
38
+ * External file schemas are hoisted into named `components.schemas` entries, so a property
39
+ * pointing at `./schemas/User.yaml` ends up referencing `#/components/schemas/User`. Generators
40
+ * can then emit a named type with an import instead of inlining the shape. Sources are read with
41
+ * the Bun-aware `read` util for local YAML and JSON files, and with `fetch` for HTTP(S) URLs.
42
+ *
43
+ * @example Local file
44
+ * `const document = await bundleDocument('./openapi.yaml')`
45
+ *
46
+ * @example Remote URL
47
+ * `const document = await bundleDocument('https://example.com/openapi.yaml')`
48
+ */
49
+ export async function bundleDocument(pathOrUrl: string): Promise<Document> {
50
+ const cache = new Map<string, Promise<object | string>>()
51
+
52
+ const resolver = (sourcePath: string) => {
53
+ // api-ref-bundler refers to the same URL as both `https://` and the posix-normalized
54
+ // `https:/`, so cache on the canonical href to fetch each source once.
55
+ const key = urlRegExp.test(sourcePath) ? new URL(sourcePath).href : sourcePath
56
+ const cached = cache.get(key)
57
+ if (cached) {
58
+ return cached
59
+ }
60
+
61
+ const result = resolveSource(sourcePath)
62
+ cache.set(key, result)
63
+ return result
64
+ }
65
+
66
+ // api-ref-bundler swallows resolver errors and leaves refs unresolved, so surface an
67
+ // unreadable input document as a hard error before bundling.
68
+ await resolver(pathOrUrl)
69
+
70
+ return (await bundle(pathOrUrl, resolver)) as Document
71
+ }
package/src/constants.ts CHANGED
@@ -31,6 +31,12 @@ export const DEFAULT_PARSER_OPTIONS = {
31
31
  */
32
32
  export const SCHEMA_REF_PREFIX = '#/components/schemas/' as const
33
33
 
34
+ /**
35
+ * HTTP methods that count as operations on an OpenAPI path item. Other keys
36
+ * (`parameters`, `summary`, `$ref`, vendor extensions) are skipped when iterating operations.
37
+ */
38
+ export const SUPPORTED_METHODS: ReadonlySet<string> = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'])
39
+
34
40
  /**
35
41
  * OpenAPI version string written into the stub document created during multi-spec merges.
36
42
  */
@@ -67,7 +73,7 @@ export const structuralKeys = new Set(['properties', 'items', 'additionalPropert
67
73
  *
68
74
  * Only formats whose AST type differs from the OAS `type` field appear here.
69
75
  * Formats that depend on runtime options (`int64`, `date-time`, `date`, `time`) are handled separately
70
- * in the parser. `ipv4` and `ipv6` map to their own dedicated schema types; `hostname` and
76
+ * in the parser. `ipv4` and `ipv6` map to their own dedicated schema types. `hostname` and
71
77
  * `idn-hostname` map to `'url'` as the closest generic string-format type.
72
78
  *
73
79
  * @example
@@ -79,6 +85,14 @@ export const structuralKeys = new Set(['properties', 'items', 'additionalPropert
79
85
  * formatMap['float'] // 'number'
80
86
  * ```
81
87
  */
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
+
82
96
  export const formatMap = {
83
97
  uuid: 'uuid',
84
98
  email: 'email',
@@ -112,8 +126,8 @@ export const formatMap = {
112
126
  export const enumExtensionKeys = ['x-enumNames', 'x-enum-varnames'] as const
113
127
 
114
128
  /**
115
- * Maps `'any' | 'unknown' | 'void'` option strings to their `ScalarSchemaType` constant.
116
- * Replaces a plain object lookup with a `Map` for explicit key membership testing via `.has()`.
129
+ * Maps the `unknownType`/`emptySchemaType` option string to its `ScalarSchemaType` constant.
130
+ * A `Map` (over a plain object) lets callers test key membership with `.has()`.
117
131
  */
118
132
  export const typeOptionMap = new Map<'any' | 'unknown' | 'void', ast.ScalarSchemaType>([
119
133
  ['any', ast.schemaTypes.any],
package/src/dialect.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { ast } from '@kubb/core'
2
+ import { isDiscriminator, isNullable, isReference } from './guards.ts'
3
+ import { resolveRef } from './refs.ts'
4
+ import type { SchemaObject } from './types.ts'
5
+
6
+ /**
7
+ * The OpenAPI / Swagger dialect, the default used by `@kubb/adapter-oas`.
8
+ *
9
+ * Implements the spec-agnostic {@link ast.SchemaDialect} contract: it isolates the
10
+ * decisions that differ between specs (nullability, `$ref`, discriminator, binary,
11
+ * ref resolution) so the converter pipeline and dispatch rules stay shared. A
12
+ * future adapter (e.g. AsyncAPI) ships its own dialect, `type: ['null', …]`
13
+ * nullability, no discriminator object, binary via `contentEncoding` and reuses
14
+ * the rest unchanged.
15
+ *
16
+ * Formats (`uuid`, `email`, dates, …) are intentionally NOT here: they are shared
17
+ * JSON Schema vocabulary, so the converters keep that common case.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const parser = createSchemaParser(context) // uses oasDialect
22
+ * const parser = createSchemaParser(context, oasDialect) // explicit
23
+ * ```
24
+ */
25
+ export const oasDialect = ast.defineSchemaDialect({
26
+ name: 'oas',
27
+ isNullable,
28
+ isReference,
29
+ isDiscriminator,
30
+ isBinary: (schema: SchemaObject) => schema.type === 'string' && schema.contentMediaType === 'application/octet-stream',
31
+ resolveRef,
32
+ })
33
+
34
+ /**
35
+ * The concrete dialect type for `@kubb/adapter-oas`. Keeps the OAS guard predicates
36
+ * (`isReference`, `isDiscriminator`) intact so converters narrow schemas after a check.
37
+ */
38
+ export type OasDialect = typeof oasDialect