@kubb/oas 4.31.0 → 4.31.2
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 +84 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +83 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/utils.spec.ts +59 -5
- package/src/utils.ts +114 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubb/oas",
|
|
3
|
-
"version": "4.31.
|
|
3
|
+
"version": "4.31.2",
|
|
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",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"openapi-types": "^12.1.3",
|
|
60
60
|
"remeda": "^2.33.6",
|
|
61
61
|
"swagger2openapi": "^7.0.8",
|
|
62
|
-
"@kubb/core": "4.31.
|
|
62
|
+
"@kubb/core": "4.31.2"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@stoplight/yaml": "^4.3.0",
|
package/src/utils.spec.ts
CHANGED
|
@@ -89,19 +89,22 @@ components:
|
|
|
89
89
|
|
|
90
90
|
expect(oas).toBeDefined()
|
|
91
91
|
expect(oas.api?.info.title).toBe('PetStore with external file ref')
|
|
92
|
-
// After bundling
|
|
92
|
+
// After bundling with external component merging, the Category schema is lifted to #/components/schemas/Category
|
|
93
93
|
const petSchema = (oas.api as any).components?.schemas?.Pet
|
|
94
94
|
expect(petSchema).toBeDefined()
|
|
95
95
|
expect(petSchema.type).toBe('object')
|
|
96
96
|
expect(petSchema.required).toEqual(['id', 'name'])
|
|
97
97
|
expect(petSchema.properties.id).toEqual({ type: 'integer', format: 'int64' })
|
|
98
98
|
expect(petSchema.properties.name).toEqual({ type: 'string' })
|
|
99
|
-
// The external
|
|
99
|
+
// The external schema is now referenced via an internal $ref, not inlined
|
|
100
100
|
const category = petSchema.properties.category
|
|
101
101
|
expect(category).toBeDefined()
|
|
102
|
-
expect(category.$ref).
|
|
103
|
-
|
|
104
|
-
|
|
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' })
|
|
105
108
|
})
|
|
106
109
|
|
|
107
110
|
test('parse a spec with an external URL $ref resolves the ref via bundling', async () => {
|
|
@@ -153,6 +156,57 @@ components:
|
|
|
153
156
|
})
|
|
154
157
|
})
|
|
155
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
|
+
})
|
|
209
|
+
|
|
156
210
|
describe('parseFromConfig', () => {
|
|
157
211
|
const petStoreV3 = path.resolve(__dirname, '../mocks/petStore.yaml')
|
|
158
212
|
const petStoreV2 = path.resolve(__dirname, '../mocks/petStoreV2.json')
|
package/src/utils.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
import type { Config } from '@kubb/core'
|
|
3
4
|
import { pascalCase } from '@kubb/core/transformers'
|
|
@@ -161,6 +162,115 @@ export function getDefaultValue(schema?: SchemaObject): string | undefined {
|
|
|
161
162
|
return undefined
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Recursively collect all external local-file $ref prefixes (e.g. "api-definitions.yml")
|
|
167
|
+
* from an object tree. URL refs (http/https) are ignored.
|
|
168
|
+
*/
|
|
169
|
+
function collectExternalFilePaths(obj: unknown, files: Set<string>): void {
|
|
170
|
+
if (!obj || typeof obj !== 'object') return
|
|
171
|
+
if (Array.isArray(obj)) {
|
|
172
|
+
for (const item of obj) collectExternalFilePaths(item, files)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
176
|
+
if (key === '$ref' && typeof value === 'string') {
|
|
177
|
+
const hashIdx = value.indexOf('#')
|
|
178
|
+
const filePart = hashIdx > 0 ? value.slice(0, hashIdx) : hashIdx === -1 ? value : ''
|
|
179
|
+
if (filePart && !filePart.startsWith('http://') && !filePart.startsWith('https://')) {
|
|
180
|
+
files.add(filePart)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
collectExternalFilePaths(value, files)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Replace all $refs that start with `externalFile#` with the corresponding
|
|
190
|
+
* internal ref (i.e. just the fragment part, `#/...`).
|
|
191
|
+
*/
|
|
192
|
+
function replaceExternalRefsInPlace(obj: unknown, externalFile: string): void {
|
|
193
|
+
if (!obj || typeof obj !== 'object') return
|
|
194
|
+
if (Array.isArray(obj)) {
|
|
195
|
+
for (const item of obj) replaceExternalRefsInPlace(item, externalFile)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
const record = obj as Record<string, unknown>
|
|
199
|
+
for (const key of Object.keys(record)) {
|
|
200
|
+
const value = record[key]
|
|
201
|
+
if (key === '$ref' && typeof value === 'string' && value.startsWith(`${externalFile}#`)) {
|
|
202
|
+
record[key] = value.slice(externalFile.length)
|
|
203
|
+
} else if (value && typeof value === 'object') {
|
|
204
|
+
replaceExternalRefsInPlace(value, externalFile)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Before bundling, scan the main spec file for external local-file references and merge
|
|
211
|
+
* their `components` sections into the main document. This ensures that schemas defined
|
|
212
|
+
* in external files (e.g. `api-definitions.yml#/components/schemas/Parcel`) end up in
|
|
213
|
+
* `#/components/schemas/Parcel` of the bundled output, rather than being inlined as
|
|
214
|
+
* anonymous path-based refs.
|
|
215
|
+
*
|
|
216
|
+
* Returns the merged document, or `null` if no external file components were found.
|
|
217
|
+
*/
|
|
218
|
+
export function mergeExternalFileComponents(mainFilePath: string): Document | null {
|
|
219
|
+
let mainContent: string
|
|
220
|
+
try {
|
|
221
|
+
mainContent = fs.readFileSync(mainFilePath, 'utf-8')
|
|
222
|
+
} catch {
|
|
223
|
+
return null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const mainDoc = yaml.parse(mainContent) as Record<string, unknown> | null
|
|
227
|
+
if (!mainDoc || typeof mainDoc !== 'object') return null
|
|
228
|
+
|
|
229
|
+
const mainDir = path.dirname(mainFilePath)
|
|
230
|
+
|
|
231
|
+
const externalFiles = new Set<string>()
|
|
232
|
+
collectExternalFilePaths(mainDoc, externalFiles)
|
|
233
|
+
|
|
234
|
+
if (externalFiles.size === 0) return null
|
|
235
|
+
|
|
236
|
+
let hasMergedComponents = false
|
|
237
|
+
|
|
238
|
+
for (const externalFile of externalFiles) {
|
|
239
|
+
const externalFilePath = path.resolve(mainDir, externalFile)
|
|
240
|
+
let externalContent: string
|
|
241
|
+
try {
|
|
242
|
+
externalContent = fs.readFileSync(externalFilePath, 'utf-8')
|
|
243
|
+
} catch {
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const externalDoc = yaml.parse(externalContent) as Record<string, unknown> | null
|
|
248
|
+
if (!externalDoc?.components || typeof externalDoc.components !== 'object') continue
|
|
249
|
+
|
|
250
|
+
const mainComponents = (mainDoc.components as Record<string, unknown>) ?? {}
|
|
251
|
+
mainDoc.components = mainComponents
|
|
252
|
+
|
|
253
|
+
for (const [componentType, components] of Object.entries(externalDoc.components as Record<string, Record<string, unknown>>)) {
|
|
254
|
+
if (!components || typeof components !== 'object') continue
|
|
255
|
+
// Spread external entries first, then main doc entries last so main document wins on name conflicts
|
|
256
|
+
mainComponents[componentType] = {
|
|
257
|
+
...(components as Record<string, unknown>),
|
|
258
|
+
...((mainComponents[componentType] as Record<string, unknown>) ?? {}),
|
|
259
|
+
}
|
|
260
|
+
hasMergedComponents = true
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!hasMergedComponents) return null
|
|
265
|
+
|
|
266
|
+
// Replace external file refs with their internal equivalents
|
|
267
|
+
for (const externalFile of externalFiles) {
|
|
268
|
+
replaceExternalRefsInPlace(mainDoc, externalFile)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return mainDoc as unknown as Document
|
|
272
|
+
}
|
|
273
|
+
|
|
164
274
|
export async function parse(
|
|
165
275
|
pathOrApi: string | Document,
|
|
166
276
|
{ oasClass = Oas, enablePaths = true }: { oasClass?: typeof Oas; canBundle?: boolean; enablePaths?: boolean } = {},
|
|
@@ -172,7 +282,10 @@ export async function parse(
|
|
|
172
282
|
// Bundle the spec using the string path directly so that relative external $refs
|
|
173
283
|
// (file and URL) are resolved with the correct base path context.
|
|
174
284
|
try {
|
|
175
|
-
|
|
285
|
+
// Pre-merge external file component schemas so they appear in #/components/schemas/
|
|
286
|
+
// of the bundled output rather than being inlined as anonymous path-based refs.
|
|
287
|
+
const mergedDoc = mergeExternalFileComponents(pathOrApi)
|
|
288
|
+
const bundled = await bundle((mergedDoc ?? pathOrApi) as Parameters<typeof bundle>[0])
|
|
176
289
|
return parse(bundled as Document, { oasClass, enablePaths })
|
|
177
290
|
} catch (e) {
|
|
178
291
|
// If bundling fails (e.g. unresolvable refs or network error), fall through to plain load
|