@kubb/adapter-oas 5.0.0-beta.53 → 5.0.0-beta.55
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/dist/index.cjs +393 -258
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +767 -10
- package/dist/index.js +393 -255
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/adapter.ts +13 -21
- package/src/bundler.ts +2 -2
- package/src/constants.ts +6 -0
- package/src/factory.ts +21 -25
- package/src/mime.ts +22 -0
- package/src/operation.ts +194 -0
- package/src/parser.ts +42 -30
- package/src/resolvers.ts +8 -9
- package/src/stream.ts +27 -23
- package/src/types.ts +46 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubb/adapter-oas",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
3
|
+
"version": "5.0.0-beta.55",
|
|
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",
|
|
@@ -42,16 +42,15 @@
|
|
|
42
42
|
"registry": "https://registry.npmjs.org/"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@readme/openapi-parser": "^6.1.3",
|
|
45
46
|
"api-ref-bundler": "0.5.1",
|
|
46
|
-
"js-yaml": "^4.2.0",
|
|
47
|
-
"oas": "^32.1.18",
|
|
48
|
-
"oas-normalize": "^16.0.5",
|
|
49
47
|
"swagger2openapi": "^7.0.8",
|
|
50
|
-
"
|
|
51
|
-
"@kubb/
|
|
48
|
+
"yaml": "^2.9.0",
|
|
49
|
+
"@kubb/ast": "5.0.0-beta.55",
|
|
50
|
+
"@kubb/core": "5.0.0-beta.55"
|
|
52
51
|
},
|
|
53
52
|
"devDependencies": {
|
|
54
|
-
"@types/
|
|
53
|
+
"@types/json-schema": "^7.0.15",
|
|
55
54
|
"@types/swagger2openapi": "^7.0.4",
|
|
56
55
|
"openapi-types": "^12.1.3",
|
|
57
56
|
"@internals/utils": "0.0.0"
|
|
@@ -60,6 +59,7 @@
|
|
|
60
59
|
"node": ">=22"
|
|
61
60
|
},
|
|
62
61
|
"inlinedDependencies": {
|
|
62
|
+
"@types/json-schema": "7.0.15",
|
|
63
63
|
"openapi-types": "12.1.3"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|
package/src/adapter.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { ast, createAdapter } from '@kubb/core'
|
|
2
2
|
import type { AdapterSource } from '@kubb/core'
|
|
3
|
-
import BaseOas from 'oas'
|
|
4
3
|
import { DEFAULT_PARSER_OPTIONS } from './constants.ts'
|
|
5
4
|
import { assertInputExists, parseDocument, parseFromConfig, validateDocument } from './factory.ts'
|
|
6
5
|
import { createSchemaParser } from './parser.ts'
|
|
7
6
|
import { getSchemas } from './resolvers.ts'
|
|
8
7
|
import { createInputStream, preScan, resolveBaseUrl } from './stream.ts'
|
|
9
8
|
import type { AdapterOas, Document } from './types.ts'
|
|
9
|
+
import { collect, narrowSchema } from '@kubb/ast'
|
|
10
|
+
import { extractRefName } from '@kubb/ast/utils'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Canonical adapter name for `@kubb/adapter-oas`. Used for driver lookups.
|
|
@@ -74,7 +75,6 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
|
|
|
74
75
|
// resulting document, so distinct configs (distinct documents) stay isolated.
|
|
75
76
|
const documentCache = new WeakMap<AdapterSource, Promise<Document>>()
|
|
76
77
|
const schemasCache = new WeakMap<Document, Promise<ReturnType<typeof getSchemas>['schemas']>>()
|
|
77
|
-
const baseOasCache = new WeakMap<Document, BaseOas>()
|
|
78
78
|
const schemaParserCache = new WeakMap<Document, ReturnType<typeof createSchemaParser>>()
|
|
79
79
|
const preScanCache = new WeakMap<Document, ReturnType<typeof preScan>>()
|
|
80
80
|
|
|
@@ -105,15 +105,6 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
|
|
|
105
105
|
return promise
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
function ensureBaseOas(document: Document): BaseOas {
|
|
109
|
-
const cached = baseOasCache.get(document)
|
|
110
|
-
if (cached) return cached
|
|
111
|
-
|
|
112
|
-
const baseOas = new BaseOas(document)
|
|
113
|
-
baseOasCache.set(document, baseOas)
|
|
114
|
-
return baseOas
|
|
115
|
-
}
|
|
116
|
-
|
|
117
108
|
function ensureSchemaParser(document: Document): ReturnType<typeof createSchemaParser> {
|
|
118
109
|
const cached = schemaParserCache.get(document)
|
|
119
110
|
if (cached) return cached
|
|
@@ -128,28 +119,26 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
|
|
|
128
119
|
schemas: ReturnType<typeof getSchemas>['schemas'],
|
|
129
120
|
parseSchema: ReturnType<typeof ensureSchemaParser>['parseSchema'],
|
|
130
121
|
parseOperation: ReturnType<typeof ensureSchemaParser>['parseOperation'],
|
|
131
|
-
baseOas: BaseOas,
|
|
132
122
|
): ReturnType<typeof preScan> {
|
|
133
123
|
const cached = preScanCache.get(document)
|
|
134
124
|
if (cached) return cached
|
|
135
125
|
|
|
136
|
-
const result = preScan({ schemas, parseSchema, parseOperation,
|
|
126
|
+
const result = preScan({ schemas, parseSchema, parseOperation, document, parserOptions, discriminator, dedupe })
|
|
137
127
|
preScanCache.set(document, result)
|
|
138
128
|
return result
|
|
139
129
|
}
|
|
140
130
|
|
|
141
|
-
async function createStream(source: AdapterSource): Promise<ast.
|
|
131
|
+
async function createStream(source: AdapterSource): Promise<ast.InputNode<true>> {
|
|
142
132
|
const document = await ensureDocument(source)
|
|
143
133
|
const schemas = await ensureSchemas(document)
|
|
144
134
|
const { parseSchema, parseOperation } = ensureSchemaParser(document)
|
|
145
|
-
const
|
|
146
|
-
const { refAliasMap, enumNames, circularNames, discriminatorChildMap, dedupePlan } = ensurePreScan(document, schemas, parseSchema, parseOperation, baseOas)
|
|
135
|
+
const { refAliasMap, enumNames, circularNames, discriminatorChildMap, dedupePlan } = ensurePreScan(document, schemas, parseSchema, parseOperation)
|
|
147
136
|
|
|
148
137
|
return createInputStream({
|
|
149
138
|
schemas,
|
|
150
139
|
parseSchema,
|
|
151
140
|
parseOperation,
|
|
152
|
-
|
|
141
|
+
document,
|
|
153
142
|
parserOptions,
|
|
154
143
|
refAliasMap,
|
|
155
144
|
discriminatorChildMap,
|
|
@@ -192,10 +181,13 @@ export const adapterOas = createAdapter<AdapterOas>((options) => {
|
|
|
192
181
|
await validateDocument(document, options)
|
|
193
182
|
},
|
|
194
183
|
getImports(node, resolve) {
|
|
195
|
-
return
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
199
191
|
const result = resolve(schemaName)
|
|
200
192
|
if (!result) return null
|
|
201
193
|
|
package/src/bundler.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { read } from '@internals/utils'
|
|
2
2
|
import { bundle } from 'api-ref-bundler'
|
|
3
|
-
import
|
|
3
|
+
import { parse } from 'yaml'
|
|
4
4
|
import type { Document } from './types.ts'
|
|
5
5
|
|
|
6
6
|
const urlRegExp = /^https?:\/+/i
|
|
@@ -29,7 +29,7 @@ async function resolveSource(sourcePath: string): Promise<object | string> {
|
|
|
29
29
|
return data
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
return
|
|
32
|
+
return parse(data) as object
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
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
|
*/
|
package/src/factory.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { exists, mergeDeep,
|
|
2
|
+
import { exists, mergeDeep, Url } from '@internals/utils'
|
|
3
3
|
import { Diagnostics } from '@kubb/core'
|
|
4
4
|
import type { AdapterSource } from '@kubb/core'
|
|
5
|
-
import
|
|
5
|
+
import { compileErrors, validate } from '@readme/openapi-parser'
|
|
6
|
+
import { parse } from 'yaml'
|
|
6
7
|
import { bundleDocument } from './bundler.ts'
|
|
7
8
|
import { MERGE_DEFAULT_TITLE, MERGE_DEFAULT_VERSION, MERGE_OPENAPI_VERSION } from './constants.ts'
|
|
8
9
|
import { isOpenApiV2Document } from './guards.ts'
|
|
@@ -10,7 +11,6 @@ import type { Document } from './types.ts'
|
|
|
10
11
|
|
|
11
12
|
export type ParseOptions = {
|
|
12
13
|
canBundle?: boolean
|
|
13
|
-
enablePaths?: boolean
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export type ValidateDocumentOptions = {
|
|
@@ -31,18 +31,16 @@ export type ValidateDocumentOptions = {
|
|
|
31
31
|
* const document = await parse(rawDocumentObject, { canBundle: false })
|
|
32
32
|
* ```
|
|
33
33
|
*/
|
|
34
|
-
export async function parseDocument(pathOrApi: string | Document, { canBundle = true
|
|
34
|
+
export async function parseDocument(pathOrApi: string | Document, { canBundle = true }: ParseOptions = {}): Promise<Document> {
|
|
35
35
|
if (typeof pathOrApi === 'string' && canBundle) {
|
|
36
36
|
const bundled = await bundleDocument(pathOrApi)
|
|
37
37
|
|
|
38
|
-
return parseDocument(bundled, { canBundle: false
|
|
38
|
+
return parseDocument(bundled, { canBundle: false })
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
45
|
-
const document = (await oasNormalize.load()) as Document
|
|
41
|
+
// A string here is always inline YAML/JSON content: file paths and URLs are read and parsed by
|
|
42
|
+
// `bundleDocument` first. `yaml.parse` also parses JSON, since JSON is a subset of YAML.
|
|
43
|
+
const document = (typeof pathOrApi === 'string' ? parse(pathOrApi) : pathOrApi) as Document
|
|
46
44
|
|
|
47
45
|
if (isOpenApiV2Document(document)) {
|
|
48
46
|
const { default: swagger2openapi } = await import('swagger2openapi')
|
|
@@ -59,7 +57,7 @@ export async function parseDocument(pathOrApi: string | Document, { canBundle =
|
|
|
59
57
|
/**
|
|
60
58
|
* Deep-merges multiple OpenAPI documents into a single `Document`.
|
|
61
59
|
*
|
|
62
|
-
* Each document is parsed independently then
|
|
60
|
+
* Each document is parsed independently, then deep-merged into one in array order.
|
|
63
61
|
* Throws when the input array is empty.
|
|
64
62
|
*
|
|
65
63
|
* @example
|
|
@@ -68,7 +66,7 @@ export async function parseDocument(pathOrApi: string | Document, { canBundle =
|
|
|
68
66
|
* ```
|
|
69
67
|
*/
|
|
70
68
|
export async function mergeDocuments(pathOrApi: Array<string | Document>): Promise<Document> {
|
|
71
|
-
const documents = await Promise.all(pathOrApi.map((p) => parseDocument(p, {
|
|
69
|
+
const documents = await Promise.all(pathOrApi.map((p) => parseDocument(p, { canBundle: false })))
|
|
72
70
|
|
|
73
71
|
if (documents.length === 0) {
|
|
74
72
|
throw new Diagnostics.Error({
|
|
@@ -123,7 +121,7 @@ export async function parseFromConfig(source: AdapterSource): Promise<Document>
|
|
|
123
121
|
}
|
|
124
122
|
|
|
125
123
|
// type === 'path'
|
|
126
|
-
if (
|
|
124
|
+
if (Url.canParse(source.path)) {
|
|
127
125
|
return parseDocument(source.path)
|
|
128
126
|
}
|
|
129
127
|
|
|
@@ -138,7 +136,7 @@ export async function parseFromConfig(source: AdapterSource): Promise<Document>
|
|
|
138
136
|
* its parse error instead.
|
|
139
137
|
*/
|
|
140
138
|
export async function assertInputExists(input: string): Promise<void> {
|
|
141
|
-
if (
|
|
139
|
+
if (Url.canParse(input)) {
|
|
142
140
|
return
|
|
143
141
|
}
|
|
144
142
|
if (!(await exists(input))) {
|
|
@@ -153,7 +151,7 @@ export async function assertInputExists(input: string): Promise<void> {
|
|
|
153
151
|
}
|
|
154
152
|
|
|
155
153
|
/**
|
|
156
|
-
* Validates an OpenAPI document using
|
|
154
|
+
* Validates an OpenAPI document using `@readme/openapi-parser` with colorized error output.
|
|
157
155
|
*
|
|
158
156
|
* @example
|
|
159
157
|
* ```ts
|
|
@@ -162,18 +160,16 @@ export async function assertInputExists(input: string): Promise<void> {
|
|
|
162
160
|
*/
|
|
163
161
|
export async function validateDocument(document: Document, { throwOnError = false }: ValidateDocumentOptions = {}): Promise<void> {
|
|
164
162
|
try {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await oasNormalize.validate({
|
|
171
|
-
parser: {
|
|
172
|
-
validate: {
|
|
173
|
-
errors: { colorize: true },
|
|
174
|
-
},
|
|
163
|
+
// `validate` dereferences its input in place, so clone to keep the cached document intact.
|
|
164
|
+
const result = await validate(structuredClone(document), {
|
|
165
|
+
validate: {
|
|
166
|
+
errors: { colorize: true },
|
|
175
167
|
},
|
|
176
168
|
})
|
|
169
|
+
|
|
170
|
+
if (!result.valid) {
|
|
171
|
+
throw new Error(compileErrors(result))
|
|
172
|
+
}
|
|
177
173
|
} catch (error) {
|
|
178
174
|
if (throwOnError) {
|
|
179
175
|
throw error
|
package/src/mime.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MIME type fragments that mark a media type as JSON-like.
|
|
3
|
+
*
|
|
4
|
+
* Matches the substrings used by the `oas` library's `matchesMimeType.json`, so a content type is
|
|
5
|
+
* JSON when it contains any of these. The `+json` entry catches structured-syntax suffixes such as
|
|
6
|
+
* `application/vnd.api+json`.
|
|
7
|
+
*/
|
|
8
|
+
const jsonMimeFragments = ['application/json', 'application/x-json', 'text/json', 'text/x-json', '+json'] as const
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns `true` when a media type string is JSON-like.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* isJsonMimeType('application/json') // true
|
|
16
|
+
* isJsonMimeType('application/vnd.api+json') // true
|
|
17
|
+
* isJsonMimeType('multipart/form-data') // false
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function isJsonMimeType(mimeType: string): boolean {
|
|
21
|
+
return jsonMimeFragments.some((fragment) => mimeType.includes(fragment))
|
|
22
|
+
}
|
package/src/operation.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { SUPPORTED_METHODS } from './constants.ts'
|
|
2
|
+
import { isReference } from './guards.ts'
|
|
3
|
+
import { isJsonMimeType } from './mime.ts'
|
|
4
|
+
import { resolveRef } from './refs.ts'
|
|
5
|
+
import type { Document, MediaTypeObject, OperationObject, PathItemObject, ReferenceObject, RequestBodyObject, ResponseObject } from './types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A single OpenAPI operation: its URL path, HTTP method, and the raw operation object.
|
|
9
|
+
*
|
|
10
|
+
* `schema` is a live reference into the document, so any in-place `$ref` resolution the resolvers
|
|
11
|
+
* perform is visible here too.
|
|
12
|
+
*/
|
|
13
|
+
export type Operation = {
|
|
14
|
+
path: string
|
|
15
|
+
method: string
|
|
16
|
+
schema: OperationObject
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The document plus the operation being read. Shared by the request/response accessors so they can
|
|
21
|
+
* resolve `$ref`s against the document.
|
|
22
|
+
*/
|
|
23
|
+
type OperationContext = {
|
|
24
|
+
document: Document
|
|
25
|
+
operation: Operation
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Slugifies a path for the `operationId` fallback: non-alphanumerics collapse to single dashes,
|
|
30
|
+
* with no leading or trailing dash.
|
|
31
|
+
*/
|
|
32
|
+
function slugify(value: string): string {
|
|
33
|
+
return value
|
|
34
|
+
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
35
|
+
.replace(/-{2,}/g, '-')
|
|
36
|
+
.replace(/^-|-$/g, '')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns the operation's `operationId`, falling back to `<method>_<slugified-path>` when absent.
|
|
41
|
+
*/
|
|
42
|
+
export function getOperationId({ path, method, schema }: Operation): string {
|
|
43
|
+
const { operationId } = schema
|
|
44
|
+
if (typeof operationId === 'string' && operationId.length > 0) {
|
|
45
|
+
return operationId
|
|
46
|
+
}
|
|
47
|
+
return `${method}_${slugify(path).toLowerCase()}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns the declared response status codes, skipping `x-` extensions and non-object entries.
|
|
52
|
+
*/
|
|
53
|
+
export function getResponseStatusCodes({ schema }: Operation): Array<string> {
|
|
54
|
+
const responses = schema.responses as Record<string, unknown> | undefined
|
|
55
|
+
if (!responses || isReference(responses)) {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
return Object.keys(responses).filter((key) => !key.startsWith('x-') && !!responses[key] && typeof responses[key] === 'object')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns the response object for a status code, resolving a `$ref` in place. `false` when absent.
|
|
63
|
+
*/
|
|
64
|
+
export function getResponseByStatusCode({ document, operation, statusCode }: OperationContext & { statusCode: string | number }): ResponseObject | false {
|
|
65
|
+
const responses = operation.schema.responses as Record<string, ResponseObject | ReferenceObject> | undefined
|
|
66
|
+
if (!responses || isReference(responses)) {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
const response = responses[statusCode]
|
|
70
|
+
if (!response) {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
if (isReference(response)) {
|
|
74
|
+
const resolved = resolveRef<ResponseObject>(document, response.$ref)
|
|
75
|
+
responses[statusCode] = resolved as ResponseObject
|
|
76
|
+
if (!resolved || isReference(resolved)) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
return resolved
|
|
80
|
+
}
|
|
81
|
+
return response
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolves the request body (dereferencing a `$ref` in place) and returns its content map, or
|
|
86
|
+
* `undefined` when the operation has no request body.
|
|
87
|
+
*/
|
|
88
|
+
function getRequestBodyContent({ document, operation }: OperationContext): Record<string, MediaTypeObject> | undefined {
|
|
89
|
+
const { schema } = operation
|
|
90
|
+
let requestBody = schema.requestBody as RequestBodyObject | ReferenceObject | undefined
|
|
91
|
+
if (!requestBody) {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
if (isReference(requestBody)) {
|
|
95
|
+
const resolved = resolveRef<RequestBodyObject>(document, requestBody.$ref)
|
|
96
|
+
;(schema as { requestBody?: unknown }).requestBody = resolved
|
|
97
|
+
if (!resolved || isReference(resolved)) {
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
requestBody = resolved
|
|
101
|
+
}
|
|
102
|
+
return requestBody.content
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns the request body media type. With `mediaType` set, returns that entry or `false`.
|
|
107
|
+
* Otherwise picks the first JSON-like media type, then the first declared one, as a
|
|
108
|
+
* `[mediaType, object]` tuple.
|
|
109
|
+
*/
|
|
110
|
+
export function getRequestContent({
|
|
111
|
+
document,
|
|
112
|
+
operation,
|
|
113
|
+
mediaType,
|
|
114
|
+
}: OperationContext & { mediaType?: string }): MediaTypeObject | false | [string, MediaTypeObject] {
|
|
115
|
+
const content = getRequestBodyContent({ document, operation })
|
|
116
|
+
if (!content) {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
if (mediaType) {
|
|
120
|
+
return mediaType in content ? content[mediaType]! : false
|
|
121
|
+
}
|
|
122
|
+
const mediaTypes = Object.keys(content)
|
|
123
|
+
const available = mediaTypes.find((mt) => isJsonMimeType(mt)) ?? mediaTypes[0]
|
|
124
|
+
return available ? [available, content[available]!] : false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns the primary request content type. Prefers a JSON-like media type (the last one wins,
|
|
129
|
+
* matching the previous behavior), then the first declared one, defaulting to `'application/json'`.
|
|
130
|
+
*/
|
|
131
|
+
export function getRequestContentType({ document, operation }: OperationContext): string {
|
|
132
|
+
const content = getRequestBodyContent({ document, operation })
|
|
133
|
+
const mediaTypes = content ? Object.keys(content) : []
|
|
134
|
+
|
|
135
|
+
let result = mediaTypes[0] ?? 'application/json'
|
|
136
|
+
for (const mt of mediaTypes) {
|
|
137
|
+
if (isJsonMimeType(mt)) {
|
|
138
|
+
result = mt
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Builds an `Operation` for every supported HTTP method on every path, in document order.
|
|
146
|
+
* `x-` path keys and unresolvable path-item `$ref`s are skipped.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* for (const operation of getOperations(document)) {
|
|
151
|
+
* parseOperation(options, operation)
|
|
152
|
+
* }
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export function getOperations(document: Document): Array<Operation> {
|
|
156
|
+
const operations: Array<Operation> = []
|
|
157
|
+
const paths = document.paths
|
|
158
|
+
if (!paths) {
|
|
159
|
+
return operations
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const path of Object.keys(paths)) {
|
|
163
|
+
if (path.startsWith('x-')) {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let pathItem = paths[path] as PathItemObject | ReferenceObject | undefined
|
|
168
|
+
if (!pathItem) {
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
if (isReference(pathItem)) {
|
|
172
|
+
const resolved = resolveRef<PathItemObject>(document, pathItem.$ref)
|
|
173
|
+
;(paths as Record<string, unknown>)[path] = resolved
|
|
174
|
+
if (!resolved || isReference(resolved)) {
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
pathItem = resolved
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const item = pathItem as Record<string, unknown>
|
|
181
|
+
for (const method of Object.keys(item)) {
|
|
182
|
+
if (!SUPPORTED_METHODS.has(method)) {
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
const schema = item[method]
|
|
186
|
+
if (!schema || typeof schema !== 'object') {
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
operations.push({ path, method, schema: schema as OperationObject })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return operations
|
|
194
|
+
}
|
package/src/parser.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { pascalCase
|
|
1
|
+
import { pascalCase } from '@internals/utils'
|
|
2
2
|
import { childName, enumPropName, extractRefName, findDiscriminator } from '@kubb/ast/utils'
|
|
3
3
|
import { ast } from '@kubb/core'
|
|
4
|
-
import BaseOas from 'oas'
|
|
5
4
|
import { DEFAULT_PARSER_OPTIONS, enumExtensionKeys, SCHEMA_REF_PREFIX, typeOptionMap } from './constants.ts'
|
|
6
5
|
import { oasDialect, type OasDialect } from './dialect.ts'
|
|
6
|
+
import { getOperationId, getOperations, getRequestContentType, getResponseByStatusCode, getResponseStatusCodes } from './operation.ts'
|
|
7
7
|
import {
|
|
8
8
|
buildSchemaNode,
|
|
9
9
|
flattenSchema,
|
|
@@ -61,11 +61,18 @@ type SchemaContext = {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* One entry in
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* One entry in the ordered schema rule table: a predicate paired with a converter. A rule whose
|
|
65
|
+
* `match` returns `true` may still `convert` to `null` to defer to the next rule (e.g. a `format`
|
|
66
|
+
* that is not convertible falls through to plain `type` handling).
|
|
67
67
|
*/
|
|
68
|
-
type SchemaRule =
|
|
68
|
+
type SchemaRule = {
|
|
69
|
+
/** Identifies the rule when reading the table or debugging which branch ran. */
|
|
70
|
+
name: string
|
|
71
|
+
/** Returns `true` when this rule is responsible for the given context. */
|
|
72
|
+
match: (context: SchemaContext) => boolean
|
|
73
|
+
/** Produces a node for the context, or `null` to fall through to the next rule. */
|
|
74
|
+
convert: (context: SchemaContext) => ast.SchemaNode | null
|
|
75
|
+
}
|
|
69
76
|
|
|
70
77
|
/**
|
|
71
78
|
* Normalizes malformed `{ type: 'array', enum: [...] }` schemas by moving enum values into items.
|
|
@@ -772,7 +779,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
|
|
|
772
779
|
}
|
|
773
780
|
|
|
774
781
|
/**
|
|
775
|
-
* Ordered schema
|
|
782
|
+
* Ordered schema rule table. Order is significant: composition keywords (`$ref`, `allOf`,
|
|
776
783
|
* `oneOf`/`anyOf`) take precedence over `const`/`format`, which take precedence over the plain
|
|
777
784
|
* `type`. The first matching rule that produces a node wins. See {@link SchemaRule} for the
|
|
778
785
|
* match/convert/fall-through contract.
|
|
@@ -815,10 +822,10 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
|
|
|
815
822
|
]
|
|
816
823
|
|
|
817
824
|
/**
|
|
818
|
-
*
|
|
825
|
+
* Converts an OAS `SchemaObject` into a `SchemaNode`.
|
|
819
826
|
*
|
|
820
|
-
* Builds the per-schema context, then
|
|
821
|
-
*
|
|
827
|
+
* Builds the per-schema context, then walks the ordered {@link schemaRules} table and returns
|
|
828
|
+
* the first converter that produces a node. When none match, falls back to the configured
|
|
822
829
|
* `emptySchemaType`.
|
|
823
830
|
*/
|
|
824
831
|
function parseSchema({ schema, name }: { schema: SchemaObject; name?: string | null }, rawOptions?: Partial<ast.ParserOptions>): ast.SchemaNode {
|
|
@@ -845,8 +852,11 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
|
|
|
845
852
|
options,
|
|
846
853
|
}
|
|
847
854
|
|
|
848
|
-
const
|
|
849
|
-
|
|
855
|
+
for (const rule of schemaRules) {
|
|
856
|
+
if (!rule.match(schemaCtx)) continue
|
|
857
|
+
const node = rule.convert(schemaCtx)
|
|
858
|
+
if (node) return node
|
|
859
|
+
}
|
|
850
860
|
|
|
851
861
|
const emptyType = typeOptionMap.get(options.emptySchemaType)!
|
|
852
862
|
return ast.createSchema({
|
|
@@ -937,7 +947,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
|
|
|
937
947
|
* Converts an OAS `Operation` into an `OperationNode`.
|
|
938
948
|
*/
|
|
939
949
|
function parseOperation(options: ast.ParserOptions, operation: Operation): ast.OperationNode {
|
|
940
|
-
const operationId =
|
|
950
|
+
const operationId = getOperationId(operation)
|
|
941
951
|
const operationName = operationId ? pascalCase(operationId) : undefined
|
|
942
952
|
const parameters: Array<ast.ParameterNode> = getParameters(document, operation).map((param) =>
|
|
943
953
|
parseParameter(options, param as unknown as Record<string, unknown>, operationName),
|
|
@@ -972,8 +982,8 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
|
|
|
972
982
|
}
|
|
973
983
|
: undefined
|
|
974
984
|
|
|
975
|
-
const responses: Array<ast.ResponseNode> =
|
|
976
|
-
const responseObj =
|
|
985
|
+
const responses: Array<ast.ResponseNode> = getResponseStatusCodes(operation).map((statusCode) => {
|
|
986
|
+
const responseObj = getResponseByStatusCode({ document, operation, statusCode })
|
|
977
987
|
|
|
978
988
|
// Use `Status<code>` (matching plugin-ts's resolveResponseStatusName convention) so the
|
|
979
989
|
// qualified names for nested enums don't collide with top-level component schemas that
|
|
@@ -998,7 +1008,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
|
|
|
998
1008
|
// Body-less responses keep a single fallback entry so the response still resolves to a
|
|
999
1009
|
// (void/any) schema, matching how `requestBody` only carries schemas inside `content`.
|
|
1000
1010
|
if (content.length === 0) {
|
|
1001
|
-
content.push({ contentType: operation
|
|
1011
|
+
content.push({ contentType: getRequestContentType({ document, operation }) || 'application/json', ...parseEntrySchema(ctx.contentType) })
|
|
1002
1012
|
}
|
|
1003
1013
|
|
|
1004
1014
|
return ast.createResponse({
|
|
@@ -1008,17 +1018,24 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
|
|
|
1008
1018
|
})
|
|
1009
1019
|
})
|
|
1010
1020
|
|
|
1011
|
-
const
|
|
1021
|
+
const pathItem = document.paths?.[operation.path]
|
|
1022
|
+
const pathItemDoc = pathItem && !dialect.isReference(pathItem) ? (pathItem as { summary?: unknown; description?: unknown }) : undefined
|
|
1023
|
+
const pickDoc = (key: 'summary' | 'description'): string | undefined => {
|
|
1024
|
+
const own = operation.schema[key]
|
|
1025
|
+
if (typeof own === 'string') return own
|
|
1026
|
+
const fallback = pathItemDoc?.[key]
|
|
1027
|
+
return typeof fallback === 'string' ? fallback : undefined
|
|
1028
|
+
}
|
|
1012
1029
|
|
|
1013
1030
|
return ast.createOperation({
|
|
1014
1031
|
operationId,
|
|
1015
1032
|
protocol: 'http',
|
|
1016
1033
|
method: operation.method.toUpperCase() as ast.HttpMethod,
|
|
1017
|
-
path:
|
|
1018
|
-
tags: operation.
|
|
1019
|
-
summary:
|
|
1020
|
-
description:
|
|
1021
|
-
deprecated: operation.
|
|
1034
|
+
path: operation.path,
|
|
1035
|
+
tags: Array.isArray(operation.schema.tags) ? operation.schema.tags.map(String) : [],
|
|
1036
|
+
summary: pickDoc('summary') || undefined,
|
|
1037
|
+
description: pickDoc('description') || undefined,
|
|
1038
|
+
deprecated: operation.schema.deprecated || undefined,
|
|
1022
1039
|
parameters,
|
|
1023
1040
|
requestBody,
|
|
1024
1041
|
responses,
|
|
@@ -1084,14 +1101,9 @@ export function parseOas(
|
|
|
1084
1101
|
|
|
1085
1102
|
const schemas: Array<ast.SchemaNode> = Object.entries(schemaObjects).map(([name, schema]) => _parseSchema({ schema, name }, mergedOptions))
|
|
1086
1103
|
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
const operations: Array<ast.OperationNode> = Object.entries(paths).flatMap(([, methods]) =>
|
|
1091
|
-
Object.entries(methods)
|
|
1092
|
-
.map(([, operation]) => (operation ? _parseOperation(mergedOptions, operation) : null))
|
|
1093
|
-
.filter((op): op is ast.OperationNode => op !== null),
|
|
1094
|
-
)
|
|
1104
|
+
const operations: Array<ast.OperationNode> = getOperations(document)
|
|
1105
|
+
.map((operation) => _parseOperation(mergedOptions, operation))
|
|
1106
|
+
.filter((op): op is ast.OperationNode => op !== null)
|
|
1095
1107
|
|
|
1096
1108
|
const root = ast.createInput({ schemas, operations })
|
|
1097
1109
|
|