@kubb/oas 4.33.2 → 4.33.3

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.33.2",
3
+ "version": "4.33.3",
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
- "@readme/openapi-parser": "^5.5.0",
55
+ "@redocly/openapi-core": "^2.21.0",
56
56
  "jsonpointer": "^5.0.1",
57
- "oas": "^28.9.0",
58
- "oas-normalize": "^15.7.1",
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.2"
62
+ "@kubb/core": "4.33.3"
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
- const params = operation
436
- .getParameters()
437
- .map((schema) => {
438
- return this.dereferenceWithRef(schema)
439
- })
440
- .filter((v) => v.in === inKey)
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 '@readme/openapi-parser'
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
- // Only attempt to bundle when pathOrApi is a file path or URL (not inline YAML/JSON strings).
278
- // Inline content (YAML strings with newlines, JSON strings starting with '{') is handled by plain load.
279
- const isPathOrUrl = typeof pathOrApi === 'string' && !pathOrApi.match(/\n/) && !pathOrApi.match(/^\s*\{/)
280
- if (isPathOrUrl && enablePaths) {
281
- // Bundle the spec using the string path directly so that relative external $refs
282
- // (file and URL) are resolved with the correct base path context.
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.')