@kubb/oas 4.33.2 → 4.33.4
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 +21 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +21 -96
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/Oas.ts +28 -6
- package/src/utils.spec.ts +0 -123
- package/src/utils.ts +9 -129
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubb/oas",
|
|
3
|
-
"version": "4.33.
|
|
3
|
+
"version": "4.33.4",
|
|
4
4
|
"description": "OpenAPI Specification (OAS) utilities and helpers for Kubb, providing parsing, normalization, and manipulation of OpenAPI/Swagger schemas.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openapi",
|
|
@@ -52,14 +52,14 @@
|
|
|
52
52
|
}
|
|
53
53
|
],
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@
|
|
55
|
+
"@redocly/openapi-core": "^2.21.1",
|
|
56
56
|
"jsonpointer": "^5.0.1",
|
|
57
|
-
"oas": "^
|
|
58
|
-
"oas-normalize": "^
|
|
57
|
+
"oas": "^31.1.2",
|
|
58
|
+
"oas-normalize": "^16.0.2",
|
|
59
59
|
"openapi-types": "^12.1.3",
|
|
60
60
|
"remeda": "^2.33.6",
|
|
61
61
|
"swagger2openapi": "^7.0.8",
|
|
62
|
-
"@kubb/core": "4.33.
|
|
62
|
+
"@kubb/core": "4.33.4"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@stoplight/yaml": "^4.3.0",
|
package/src/Oas.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import jsonpointer from 'jsonpointer'
|
|
2
2
|
import BaseOas from 'oas'
|
|
3
|
+
import type { ParameterObject } from 'oas/types'
|
|
3
4
|
import { matchesMimeType } from 'oas/utils'
|
|
4
5
|
import type { contentType, DiscriminatorObject, Document, MediaTypeObject, Operation, ReferenceObject, ResponseObject, SchemaObject } from './types.ts'
|
|
5
6
|
import {
|
|
@@ -432,12 +433,33 @@ export class Oas extends BaseOas {
|
|
|
432
433
|
|
|
433
434
|
getParametersSchema(operation: Operation, inKey: 'path' | 'query' | 'header'): SchemaObject | null {
|
|
434
435
|
const { contentType = operation.getContentType() } = this.#options
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
436
|
+
|
|
437
|
+
// Collect parameters from both operation-level and path-level, resolving $ref pointers.
|
|
438
|
+
// oas v31+ filters out $ref parameters in getParameters(), so we access raw parameters
|
|
439
|
+
// directly and resolve refs ourselves to preserve backward compatibility.
|
|
440
|
+
// Note: dereferenceWithRef preserves the $ref property on resolved objects, so we check
|
|
441
|
+
// for 'in' and 'name' fields to validate successful resolution instead of !isReference().
|
|
442
|
+
const resolveParams = (params: unknown[]): Array<ParameterObject> =>
|
|
443
|
+
params.map((p) => this.dereferenceWithRef(p)).filter((p): p is ParameterObject => !!p && typeof p === 'object' && 'in' in p && 'name' in p)
|
|
444
|
+
|
|
445
|
+
const operationParams = resolveParams(operation.schema?.parameters || [])
|
|
446
|
+
const pathItem = this.api?.paths?.[operation.path]
|
|
447
|
+
const pathLevelParams = resolveParams(pathItem && !isReference(pathItem) && pathItem.parameters ? pathItem.parameters : [])
|
|
448
|
+
|
|
449
|
+
// Deduplicate: operation-level parameters override path-level ones with the same name+in
|
|
450
|
+
const paramMap = new Map<string, ParameterObject>()
|
|
451
|
+
for (const p of pathLevelParams) {
|
|
452
|
+
if (p.name && p.in) {
|
|
453
|
+
paramMap.set(`${p.in}:${p.name}`, p)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const p of operationParams) {
|
|
457
|
+
if (p.name && p.in) {
|
|
458
|
+
paramMap.set(`${p.in}:${p.name}`, p)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const params = Array.from(paramMap.values()).filter((v) => v.in === inKey)
|
|
441
463
|
|
|
442
464
|
if (!params.length) {
|
|
443
465
|
return null
|
package/src/utils.spec.ts
CHANGED
|
@@ -82,129 +82,6 @@ components:
|
|
|
82
82
|
)
|
|
83
83
|
expect(oas.api?.info.title).toBe('Swagger PetStore')
|
|
84
84
|
})
|
|
85
|
-
|
|
86
|
-
test('parse a spec with an external file $ref resolves the ref via bundling', async () => {
|
|
87
|
-
const specPath = path.resolve(__dirname, '../mocks/petStoreExternalFileRef.yaml')
|
|
88
|
-
const oas = await parse(specPath)
|
|
89
|
-
|
|
90
|
-
expect(oas).toBeDefined()
|
|
91
|
-
expect(oas.api?.info.title).toBe('PetStore with external file ref')
|
|
92
|
-
// After bundling with external component merging, the Category schema is lifted to #/components/schemas/Category
|
|
93
|
-
const petSchema = (oas.api as any).components?.schemas?.Pet
|
|
94
|
-
expect(petSchema).toBeDefined()
|
|
95
|
-
expect(petSchema.type).toBe('object')
|
|
96
|
-
expect(petSchema.required).toEqual(['id', 'name'])
|
|
97
|
-
expect(petSchema.properties.id).toEqual({ type: 'integer', format: 'int64' })
|
|
98
|
-
expect(petSchema.properties.name).toEqual({ type: 'string' })
|
|
99
|
-
// The external schema is now referenced via an internal $ref, not inlined
|
|
100
|
-
const category = petSchema.properties.category
|
|
101
|
-
expect(category).toBeDefined()
|
|
102
|
-
expect(category.$ref).toBe('#/components/schemas/Category')
|
|
103
|
-
// The Category schema is available in the components section
|
|
104
|
-
const categorySchema = (oas.api as any).components?.schemas?.Category
|
|
105
|
-
expect(categorySchema).toBeDefined()
|
|
106
|
-
expect(categorySchema.type).toBe('object')
|
|
107
|
-
expect(categorySchema.properties.id).toEqual({ type: 'integer', format: 'int64' })
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
test('parse a spec with an external URL $ref resolves the ref via bundling', async () => {
|
|
111
|
-
const specPath = path.resolve(__dirname, '../../plugin-ts/mocks/petStore.yaml')
|
|
112
|
-
const oas = await parse(specPath)
|
|
113
|
-
|
|
114
|
-
expect(oas).toBeDefined()
|
|
115
|
-
expect(oas.api?.info.title).toBe('Swagger PetStore')
|
|
116
|
-
const petSchema = (oas.api as any).components?.schemas?.Pet
|
|
117
|
-
expect(petSchema).toBeDefined()
|
|
118
|
-
expect(petSchema.type).toBe('object')
|
|
119
|
-
expect(petSchema.required).toEqual(['id', 'name'])
|
|
120
|
-
expect(petSchema.properties.id).toEqual({ type: 'integer', format: 'int64' })
|
|
121
|
-
expect(petSchema.properties.name).toEqual({ type: 'string' })
|
|
122
|
-
expect(petSchema.properties.tag).toEqual({ type: 'string' })
|
|
123
|
-
// After bundling the URL $ref is resolved (inlined) when network is available;
|
|
124
|
-
// on network failure the fallback plain load preserves the original $ref pointer.
|
|
125
|
-
expect(petSchema.properties.category).toBeDefined()
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('dereference a spec with external $refs works after bundling', async () => {
|
|
129
|
-
const specPath = path.resolve(__dirname, '../mocks/petStoreExternalFileRef.yaml')
|
|
130
|
-
const oas = await parse(specPath)
|
|
131
|
-
|
|
132
|
-
// After bundling, external file refs are resolved inline so dereference() should work without issues
|
|
133
|
-
await expect(oas.dereference()).resolves.not.toThrow()
|
|
134
|
-
|
|
135
|
-
const petSchema = (oas.api as any).components?.schemas?.Pet
|
|
136
|
-
expect(petSchema).toBeDefined()
|
|
137
|
-
expect(petSchema.type).toBe('object')
|
|
138
|
-
// After bundling, the category is inlined (no external $ref remaining)
|
|
139
|
-
const category = petSchema.properties.category
|
|
140
|
-
expect(category).toBeDefined()
|
|
141
|
-
expect(category.$ref).toBeUndefined()
|
|
142
|
-
expect(category.type).toBe('object')
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
test('dereference a spec with an external URL $ref works after bundling', async () => {
|
|
146
|
-
const specPath = path.resolve(__dirname, '../../plugin-ts/mocks/petStore.yaml')
|
|
147
|
-
const oas = await parse(specPath)
|
|
148
|
-
|
|
149
|
-
// After bundling (or fallback to load on network error), dereference() should work
|
|
150
|
-
await expect(oas.dereference()).resolves.not.toThrow()
|
|
151
|
-
|
|
152
|
-
const petSchema = (oas.api as any).components?.schemas?.Pet
|
|
153
|
-
expect(petSchema).toBeDefined()
|
|
154
|
-
expect(petSchema.type).toBe('object')
|
|
155
|
-
expect(petSchema.properties.category).toBeDefined()
|
|
156
|
-
})
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
describe('mergeExternalFileComponents', () => {
|
|
160
|
-
test('merges external component schemas so they appear in the bundled #/components/schemas', async () => {
|
|
161
|
-
const specPath = path.resolve(__dirname, '../mocks/multiFileApi.yaml')
|
|
162
|
-
const oas = await parse(specPath)
|
|
163
|
-
|
|
164
|
-
// All schemas from the external file should be available in #/components/schemas
|
|
165
|
-
const schemas = (oas.api as any).components?.schemas
|
|
166
|
-
expect(schemas).toBeDefined()
|
|
167
|
-
expect(schemas.Parcel).toBeDefined()
|
|
168
|
-
expect(schemas.ContactDetailsType).toBeDefined()
|
|
169
|
-
expect(schemas.TypeARequest).toBeDefined()
|
|
170
|
-
expect(schemas.TypeBRequest).toBeDefined()
|
|
171
|
-
expect(schemas.Result).toBeDefined()
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
test('shared external response schema is referenced via #/components, not inlined per operation', async () => {
|
|
175
|
-
const specPath = path.resolve(__dirname, '../mocks/multiFileApi.yaml')
|
|
176
|
-
const oas = await parse(specPath)
|
|
177
|
-
|
|
178
|
-
// The Result schema should be referenced via $ref, not inlined
|
|
179
|
-
const resultSchema = (oas.api as any).components?.schemas?.Result
|
|
180
|
-
expect(resultSchema).toBeDefined()
|
|
181
|
-
expect(resultSchema.type).toBe('object')
|
|
182
|
-
|
|
183
|
-
// Parcel is a nested schema that should remain separate
|
|
184
|
-
const parcelSchema = (oas.api as any).components?.schemas?.Parcel
|
|
185
|
-
expect(parcelSchema).toBeDefined()
|
|
186
|
-
expect(parcelSchema.type).toBe('object')
|
|
187
|
-
expect(parcelSchema.properties.parcelNo).toEqual({ type: 'string' })
|
|
188
|
-
expect(parcelSchema.properties.trackingNumber).toEqual({ type: 'string' })
|
|
189
|
-
|
|
190
|
-
// Result.parcels items should reference Parcel via $ref
|
|
191
|
-
const parcelsItems = resultSchema.properties?.parcels?.items
|
|
192
|
-
expect(parcelsItems).toBeDefined()
|
|
193
|
-
expect(parcelsItems.$ref).toBe('#/components/schemas/Parcel')
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
test('getSchemas returns all external component schemas for separate file generation', async () => {
|
|
197
|
-
const specPath = path.resolve(__dirname, '../mocks/multiFileApi.yaml')
|
|
198
|
-
const oas = await parse(specPath)
|
|
199
|
-
const { schemas } = oas.getSchemas()
|
|
200
|
-
|
|
201
|
-
// All schemas from the external definitions file should be available
|
|
202
|
-
expect(Object.keys(schemas)).toContain('Parcel')
|
|
203
|
-
expect(Object.keys(schemas)).toContain('ContactDetailsType')
|
|
204
|
-
expect(Object.keys(schemas)).toContain('TypeARequest')
|
|
205
|
-
expect(Object.keys(schemas)).toContain('TypeBRequest')
|
|
206
|
-
expect(Object.keys(schemas)).toContain('Result')
|
|
207
|
-
})
|
|
208
85
|
})
|
|
209
86
|
|
|
210
87
|
describe('parseFromConfig', () => {
|
package/src/utils.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
1
|
import path from 'node:path'
|
|
3
2
|
import { pascalCase, URLPath } from '@internals/utils'
|
|
4
3
|
import type { Config } from '@kubb/core'
|
|
5
|
-
import { bundle } from '@
|
|
4
|
+
import { bundle, loadConfig } from '@redocly/openapi-core'
|
|
6
5
|
import yaml from '@stoplight/yaml'
|
|
7
6
|
import type { ParameterObject, SchemaObject } from 'oas/types'
|
|
8
7
|
import { isRef, isSchema } from 'oas/types'
|
|
@@ -161,135 +160,16 @@ export function getDefaultValue(schema?: SchemaObject): string | undefined {
|
|
|
161
160
|
return undefined
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
/**
|
|
165
|
-
* Recursively collect all external local-file $ref prefixes (e.g. "api-definitions.yml")
|
|
166
|
-
* from an object tree. URL refs (http/https) are ignored.
|
|
167
|
-
*/
|
|
168
|
-
function collectExternalFilePaths(obj: unknown, files: Set<string>): void {
|
|
169
|
-
if (!obj || typeof obj !== 'object') return
|
|
170
|
-
if (Array.isArray(obj)) {
|
|
171
|
-
for (const item of obj) collectExternalFilePaths(item, files)
|
|
172
|
-
return
|
|
173
|
-
}
|
|
174
|
-
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
175
|
-
if (key === '$ref' && typeof value === 'string') {
|
|
176
|
-
const hashIdx = value.indexOf('#')
|
|
177
|
-
const filePart = hashIdx > 0 ? value.slice(0, hashIdx) : hashIdx === -1 ? value : ''
|
|
178
|
-
if (filePart && !filePart.startsWith('http://') && !filePart.startsWith('https://')) {
|
|
179
|
-
files.add(filePart)
|
|
180
|
-
}
|
|
181
|
-
} else {
|
|
182
|
-
collectExternalFilePaths(value, files)
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Replace all $refs that start with `externalFile#` with the corresponding
|
|
189
|
-
* internal ref (i.e. just the fragment part, `#/...`).
|
|
190
|
-
*/
|
|
191
|
-
function replaceExternalRefsInPlace(obj: unknown, externalFile: string): void {
|
|
192
|
-
if (!obj || typeof obj !== 'object') return
|
|
193
|
-
if (Array.isArray(obj)) {
|
|
194
|
-
for (const item of obj) replaceExternalRefsInPlace(item, externalFile)
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
const record = obj as Record<string, unknown>
|
|
198
|
-
for (const key of Object.keys(record)) {
|
|
199
|
-
const value = record[key]
|
|
200
|
-
if (key === '$ref' && typeof value === 'string' && value.startsWith(`${externalFile}#`)) {
|
|
201
|
-
record[key] = value.slice(externalFile.length)
|
|
202
|
-
} else if (value && typeof value === 'object') {
|
|
203
|
-
replaceExternalRefsInPlace(value, externalFile)
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Before bundling, scan the main spec file for external local-file references and merge
|
|
210
|
-
* their `components` sections into the main document. This ensures that schemas defined
|
|
211
|
-
* in external files (e.g. `api-definitions.yml#/components/schemas/Parcel`) end up in
|
|
212
|
-
* `#/components/schemas/Parcel` of the bundled output, rather than being inlined as
|
|
213
|
-
* anonymous path-based refs.
|
|
214
|
-
*
|
|
215
|
-
* Returns the merged document, or `null` if no external file components were found.
|
|
216
|
-
*/
|
|
217
|
-
export function mergeExternalFileComponents(mainFilePath: string): Document | null {
|
|
218
|
-
let mainContent: string
|
|
219
|
-
try {
|
|
220
|
-
mainContent = fs.readFileSync(mainFilePath, 'utf-8')
|
|
221
|
-
} catch {
|
|
222
|
-
return null
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const mainDoc = yaml.parse(mainContent) as Record<string, unknown> | null
|
|
226
|
-
if (!mainDoc || typeof mainDoc !== 'object') return null
|
|
227
|
-
|
|
228
|
-
const mainDir = path.dirname(mainFilePath)
|
|
229
|
-
|
|
230
|
-
const externalFiles = new Set<string>()
|
|
231
|
-
collectExternalFilePaths(mainDoc, externalFiles)
|
|
232
|
-
|
|
233
|
-
if (externalFiles.size === 0) return null
|
|
234
|
-
|
|
235
|
-
let hasMergedComponents = false
|
|
236
|
-
|
|
237
|
-
for (const externalFile of externalFiles) {
|
|
238
|
-
const externalFilePath = path.resolve(mainDir, externalFile)
|
|
239
|
-
let externalContent: string
|
|
240
|
-
try {
|
|
241
|
-
externalContent = fs.readFileSync(externalFilePath, 'utf-8')
|
|
242
|
-
} catch {
|
|
243
|
-
continue
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const externalDoc = yaml.parse(externalContent) as Record<string, unknown> | null
|
|
247
|
-
if (!externalDoc?.components || typeof externalDoc.components !== 'object') continue
|
|
248
|
-
|
|
249
|
-
const mainComponents = (mainDoc.components as Record<string, unknown>) ?? {}
|
|
250
|
-
mainDoc.components = mainComponents
|
|
251
|
-
|
|
252
|
-
for (const [componentType, components] of Object.entries(externalDoc.components as Record<string, Record<string, unknown>>)) {
|
|
253
|
-
if (!components || typeof components !== 'object') continue
|
|
254
|
-
// Spread external entries first, then main doc entries last so main document wins on name conflicts
|
|
255
|
-
mainComponents[componentType] = {
|
|
256
|
-
...(components as Record<string, unknown>),
|
|
257
|
-
...((mainComponents[componentType] as Record<string, unknown>) ?? {}),
|
|
258
|
-
}
|
|
259
|
-
hasMergedComponents = true
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (!hasMergedComponents) return null
|
|
264
|
-
|
|
265
|
-
// Replace external file refs with their internal equivalents
|
|
266
|
-
for (const externalFile of externalFiles) {
|
|
267
|
-
replaceExternalRefsInPlace(mainDoc, externalFile)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return mainDoc as unknown as Document
|
|
271
|
-
}
|
|
272
|
-
|
|
273
163
|
export async function parse(
|
|
274
164
|
pathOrApi: string | Document,
|
|
275
|
-
{ oasClass = Oas, enablePaths = true }: { oasClass?: typeof Oas; canBundle?: boolean; enablePaths?: boolean } = {},
|
|
165
|
+
{ oasClass = Oas, canBundle = true, enablePaths = true }: { oasClass?: typeof Oas; canBundle?: boolean; enablePaths?: boolean } = {},
|
|
276
166
|
): Promise<Oas> {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
// Pre-merge external file component schemas so they appear in #/components/schemas/
|
|
285
|
-
// of the bundled output rather than being inlined as anonymous path-based refs.
|
|
286
|
-
const mergedDoc = mergeExternalFileComponents(pathOrApi)
|
|
287
|
-
const bundled = await bundle((mergedDoc ?? pathOrApi) as Parameters<typeof bundle>[0])
|
|
288
|
-
return parse(bundled as Document, { oasClass, enablePaths })
|
|
289
|
-
} catch (e) {
|
|
290
|
-
// If bundling fails (e.g. unresolvable refs or network error), fall through to plain load
|
|
291
|
-
console.warn(`[kubb] Failed to bundle external $refs in "${pathOrApi}": ${(e as Error).message}. Falling back to plain load.`)
|
|
292
|
-
}
|
|
167
|
+
if (typeof pathOrApi === 'string' && canBundle) {
|
|
168
|
+
// resolve external refs
|
|
169
|
+
const config = await loadConfig()
|
|
170
|
+
const bundleResults = await bundle({ ref: pathOrApi, config, base: pathOrApi })
|
|
171
|
+
|
|
172
|
+
return parse(bundleResults.bundle.parsed as string, { oasClass, canBundle, enablePaths })
|
|
293
173
|
}
|
|
294
174
|
|
|
295
175
|
const oasNormalize = new OASNormalize(pathOrApi, {
|
|
@@ -310,7 +190,7 @@ export async function parse(
|
|
|
310
190
|
}
|
|
311
191
|
|
|
312
192
|
export async function merge(pathOrApi: Array<string | Document>, { oasClass = Oas }: { oasClass?: typeof Oas } = {}): Promise<Oas> {
|
|
313
|
-
const instances = await Promise.all(pathOrApi.map((p) => parse(p, { oasClass, enablePaths: false })))
|
|
193
|
+
const instances = await Promise.all(pathOrApi.map((p) => parse(p, { oasClass, enablePaths: false, canBundle: false })))
|
|
314
194
|
|
|
315
195
|
if (instances.length === 0) {
|
|
316
196
|
throw new Error('No OAS instances provided for merging.')
|