@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubb/oas",
3
- "version": "4.31.0",
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.0"
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, the external file $ref is resolved: the Category schema is inlined
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 file $ref is resolved: category has the inline schema from category.yaml
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).toBeUndefined()
103
- expect(category.type).toBe('object')
104
- expect(category.properties.id).toEqual({ type: 'integer', format: 'int64' })
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
- const bundled = await bundle(pathOrApi)
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