@kubb/oas 4.18.5 → 4.19.1
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 +197 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -14
- package/dist/index.d.ts +25 -14
- package/dist/index.js +197 -23
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/Oas.ts +68 -41
- package/src/index.ts +1 -0
- package/src/utils.spec.ts +288 -1
- package/src/utils.ts +259 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubb/oas",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.19.1",
|
|
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",
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
}
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@redocly/openapi-core": "^2.14.
|
|
51
|
+
"@redocly/openapi-core": "^2.14.9",
|
|
52
52
|
"jsonpointer": "^5.0.1",
|
|
53
53
|
"oas": "^28.9.0",
|
|
54
54
|
"oas-normalize": "^15.7.0",
|
|
55
55
|
"openapi-types": "^12.1.3",
|
|
56
56
|
"remeda": "^2.33.4",
|
|
57
57
|
"swagger2openapi": "^7.0.8",
|
|
58
|
-
"@kubb/core": "4.
|
|
58
|
+
"@kubb/core": "4.19.1"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@stoplight/yaml": "^4.3.0",
|
package/src/Oas.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
import jsonpointer from 'jsonpointer'
|
|
2
2
|
import BaseOas from 'oas'
|
|
3
|
-
|
|
4
3
|
import { matchesMimeType } from 'oas/utils'
|
|
5
4
|
import type { contentType, DiscriminatorObject, Document, MediaTypeObject, Operation, ReferenceObject, ResponseObject, SchemaObject } from './types.ts'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import {
|
|
6
|
+
extractSchemaFromContent,
|
|
7
|
+
flattenSchema,
|
|
8
|
+
isDiscriminator,
|
|
9
|
+
isReference,
|
|
10
|
+
legacyResolve,
|
|
11
|
+
resolveCollisions,
|
|
12
|
+
type SchemaWithMetadata,
|
|
13
|
+
sortSchemas,
|
|
14
|
+
validate,
|
|
15
|
+
} from './utils.ts'
|
|
16
|
+
|
|
17
|
+
type OasOptions = {
|
|
9
18
|
contentType?: contentType
|
|
10
19
|
discriminator?: 'strict' | 'inherit'
|
|
20
|
+
/**
|
|
21
|
+
* Resolve name collisions when schemas from different components share the same name (case-insensitive).
|
|
22
|
+
* @default false
|
|
23
|
+
*/
|
|
24
|
+
collisionDetection?: boolean
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
export class Oas extends BaseOas {
|
|
14
|
-
#options:
|
|
28
|
+
#options: OasOptions = {
|
|
15
29
|
discriminator: 'strict',
|
|
16
30
|
}
|
|
17
31
|
document: Document
|
|
@@ -22,7 +36,7 @@ export class Oas extends BaseOas {
|
|
|
22
36
|
this.document = document
|
|
23
37
|
}
|
|
24
38
|
|
|
25
|
-
setOptions(options:
|
|
39
|
+
setOptions(options: OasOptions) {
|
|
26
40
|
this.#options = {
|
|
27
41
|
...this.#options,
|
|
28
42
|
...options,
|
|
@@ -33,7 +47,7 @@ export class Oas extends BaseOas {
|
|
|
33
47
|
}
|
|
34
48
|
}
|
|
35
49
|
|
|
36
|
-
get options():
|
|
50
|
+
get options(): OasOptions {
|
|
37
51
|
return this.#options
|
|
38
52
|
}
|
|
39
53
|
|
|
@@ -480,51 +494,64 @@ export class Oas extends BaseOas {
|
|
|
480
494
|
}
|
|
481
495
|
|
|
482
496
|
async validate() {
|
|
483
|
-
|
|
484
|
-
const oasNormalize = new OASNormalize(this.api, {
|
|
485
|
-
enablePaths: true,
|
|
486
|
-
colorizeErrors: true,
|
|
487
|
-
})
|
|
488
|
-
|
|
489
|
-
return oasNormalize.validate({
|
|
490
|
-
parser: {
|
|
491
|
-
validate: {
|
|
492
|
-
errors: {
|
|
493
|
-
colorize: true,
|
|
494
|
-
},
|
|
495
|
-
},
|
|
496
|
-
},
|
|
497
|
-
})
|
|
497
|
+
return validate(this.api)
|
|
498
498
|
}
|
|
499
499
|
|
|
500
500
|
flattenSchema(schema: SchemaObject | null): SchemaObject | null {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
501
|
+
return flattenSchema(schema)
|
|
502
|
+
}
|
|
504
503
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
504
|
+
/**
|
|
505
|
+
* Get schemas from OpenAPI components (schemas, responses, requestBodies).
|
|
506
|
+
* Returns schemas in dependency order along with name mapping for collision resolution.
|
|
507
|
+
*/
|
|
508
|
+
getSchemas(options: { contentType?: contentType; includes?: Array<'schemas' | 'responses' | 'requestBodies'>; collisionDetection?: boolean } = {}): {
|
|
509
|
+
schemas: Record<string, SchemaObject>
|
|
510
|
+
nameMapping: Map<string, string>
|
|
511
|
+
} {
|
|
512
|
+
const contentType = options.contentType ?? this.#options.contentType
|
|
513
|
+
const includes = options.includes ?? ['schemas', 'requestBodies', 'responses']
|
|
514
|
+
const shouldResolveCollisions = options.collisionDetection ?? this.#options.collisionDetection ?? false
|
|
515
|
+
|
|
516
|
+
const components = this.getDefinition().components
|
|
517
|
+
const schemasWithMeta: SchemaWithMetadata[] = []
|
|
518
|
+
|
|
519
|
+
// Collect schemas from components
|
|
520
|
+
if (includes.includes('schemas')) {
|
|
521
|
+
const componentSchemas = (components?.schemas as Record<string, SchemaObject>) || {}
|
|
522
|
+
for (const [name, schema] of Object.entries(componentSchemas)) {
|
|
523
|
+
schemasWithMeta.push({ schema, source: 'schemas', originalName: name })
|
|
524
|
+
}
|
|
508
525
|
}
|
|
509
526
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
527
|
+
if (includes.includes('responses')) {
|
|
528
|
+
const responses = components?.responses || {}
|
|
529
|
+
for (const [name, response] of Object.entries(responses)) {
|
|
530
|
+
const responseObject = response as ResponseObject
|
|
531
|
+
const schema = extractSchemaFromContent(responseObject.content, contentType)
|
|
532
|
+
if (schema) {
|
|
533
|
+
schemasWithMeta.push({ schema, source: 'responses', originalName: name })
|
|
534
|
+
}
|
|
535
|
+
}
|
|
515
536
|
}
|
|
516
537
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (
|
|
523
|
-
|
|
538
|
+
if (includes.includes('requestBodies')) {
|
|
539
|
+
const requestBodies = components?.requestBodies || {}
|
|
540
|
+
for (const [name, request] of Object.entries(requestBodies)) {
|
|
541
|
+
const requestObject = request as { content?: Record<string, unknown> }
|
|
542
|
+
const schema = extractSchemaFromContent(requestObject.content, contentType)
|
|
543
|
+
if (schema) {
|
|
544
|
+
schemasWithMeta.push({ schema, source: 'requestBodies', originalName: name })
|
|
524
545
|
}
|
|
525
546
|
}
|
|
526
547
|
}
|
|
527
548
|
|
|
528
|
-
|
|
549
|
+
// Apply collision resolution only if enabled
|
|
550
|
+
const { schemas, nameMapping } = shouldResolveCollisions ? resolveCollisions(schemasWithMeta) : legacyResolve(schemasWithMeta)
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
schemas: sortSchemas(schemas),
|
|
554
|
+
nameMapping,
|
|
555
|
+
}
|
|
529
556
|
}
|
|
530
557
|
}
|
package/src/index.ts
CHANGED
package/src/utils.spec.ts
CHANGED
|
@@ -3,7 +3,19 @@ import { fileURLToPath } from 'node:url'
|
|
|
3
3
|
import type { Config } from '@kubb/core'
|
|
4
4
|
import yaml from '@stoplight/yaml'
|
|
5
5
|
import { describe, expect, test } from 'vitest'
|
|
6
|
-
import {
|
|
6
|
+
import type { SchemaObject } from './types.ts'
|
|
7
|
+
import {
|
|
8
|
+
collectRefs,
|
|
9
|
+
extractSchemaFromContent,
|
|
10
|
+
getSemanticSuffix,
|
|
11
|
+
legacyResolve,
|
|
12
|
+
merge,
|
|
13
|
+
parse,
|
|
14
|
+
parseFromConfig,
|
|
15
|
+
resolveCollisions,
|
|
16
|
+
type SchemaWithMetadata,
|
|
17
|
+
sortSchemas,
|
|
18
|
+
} from './utils.ts'
|
|
7
19
|
|
|
8
20
|
const __filename = fileURLToPath(import.meta.url)
|
|
9
21
|
const __dirname = path.dirname(__filename)
|
|
@@ -184,4 +196,279 @@ components:
|
|
|
184
196
|
expect(oas).toBeDefined()
|
|
185
197
|
expect(oas.api?.info.title).toBe('Swagger PetStore')
|
|
186
198
|
})
|
|
199
|
+
|
|
200
|
+
describe('collectRefs', () => {
|
|
201
|
+
test('should collect $ref from schema', () => {
|
|
202
|
+
const schema = {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
user: { $ref: '#/components/schemas/User' },
|
|
206
|
+
role: { $ref: '#/components/schemas/Role' },
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
const refs = collectRefs(schema)
|
|
210
|
+
expect(refs).toEqual(new Set(['User', 'Role']))
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('should collect nested $refs', () => {
|
|
214
|
+
const schema = {
|
|
215
|
+
allOf: [
|
|
216
|
+
{ $ref: '#/components/schemas/Base' },
|
|
217
|
+
{
|
|
218
|
+
type: 'object',
|
|
219
|
+
properties: {
|
|
220
|
+
child: { $ref: '#/components/schemas/Child' },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
}
|
|
225
|
+
const refs = collectRefs(schema)
|
|
226
|
+
expect(refs).toEqual(new Set(['Base', 'Child']))
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('should handle arrays', () => {
|
|
230
|
+
const schema = {
|
|
231
|
+
type: 'array',
|
|
232
|
+
items: [{ $ref: '#/components/schemas/Item1' }, { $ref: '#/components/schemas/Item2' }],
|
|
233
|
+
}
|
|
234
|
+
const refs = collectRefs(schema)
|
|
235
|
+
expect(refs).toEqual(new Set(['Item1', 'Item2']))
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('should ignore non-component $refs', () => {
|
|
239
|
+
const schema = {
|
|
240
|
+
properties: {
|
|
241
|
+
external: { $ref: 'http://example.com/schema' },
|
|
242
|
+
internal: { $ref: '#/components/schemas/Internal' },
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
const refs = collectRefs(schema)
|
|
246
|
+
expect(refs).toEqual(new Set(['Internal']))
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
describe('sortSchemas', () => {
|
|
251
|
+
test('should sort schemas by dependencies', () => {
|
|
252
|
+
const schemas: Record<string, SchemaObject> = {
|
|
253
|
+
Parent: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
properties: {
|
|
256
|
+
child: { $ref: '#/components/schemas/Child' },
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
Child: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
name: { type: 'string' },
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const sorted = sortSchemas(schemas)
|
|
268
|
+
const keys = Object.keys(sorted)
|
|
269
|
+
|
|
270
|
+
// Child should come before Parent
|
|
271
|
+
expect(keys.indexOf('Child')).toBeLessThan(keys.indexOf('Parent'))
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('should handle circular dependencies', () => {
|
|
275
|
+
const schemas: Record<string, SchemaObject> = {
|
|
276
|
+
A: {
|
|
277
|
+
type: 'object',
|
|
278
|
+
properties: {
|
|
279
|
+
b: { $ref: '#/components/schemas/B' },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
B: {
|
|
283
|
+
type: 'object',
|
|
284
|
+
properties: {
|
|
285
|
+
a: { $ref: '#/components/schemas/A' },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Should not throw
|
|
291
|
+
const sorted = sortSchemas(schemas)
|
|
292
|
+
expect(Object.keys(sorted)).toHaveLength(2)
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
describe('extractSchemaFromContent', () => {
|
|
297
|
+
test('should extract schema from application/json content', () => {
|
|
298
|
+
const content = {
|
|
299
|
+
'application/json': {
|
|
300
|
+
schema: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
properties: {
|
|
303
|
+
id: { type: 'string' },
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const schema = extractSchemaFromContent(content)
|
|
310
|
+
expect(schema).toEqual({
|
|
311
|
+
type: 'object',
|
|
312
|
+
properties: {
|
|
313
|
+
id: { type: 'string' },
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('should use preferred content type', () => {
|
|
319
|
+
const content = {
|
|
320
|
+
'application/json': {
|
|
321
|
+
schema: { type: 'string' },
|
|
322
|
+
},
|
|
323
|
+
'application/xml': {
|
|
324
|
+
schema: { type: 'number' },
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const schema = extractSchemaFromContent(content, 'application/xml')
|
|
329
|
+
expect(schema).toEqual({ type: 'number' })
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('should return null for $ref schemas', () => {
|
|
333
|
+
const content = {
|
|
334
|
+
'application/json': {
|
|
335
|
+
schema: {
|
|
336
|
+
$ref: '#/components/schemas/User',
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const schema = extractSchemaFromContent(content)
|
|
342
|
+
expect(schema).toBeNull()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('should return null for undefined content', () => {
|
|
346
|
+
const schema = extractSchemaFromContent(undefined)
|
|
347
|
+
expect(schema).toBeNull()
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
describe('getSemanticSuffix', () => {
|
|
352
|
+
test('should return correct suffix for schemas', () => {
|
|
353
|
+
expect(getSemanticSuffix('schemas')).toBe('Schema')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('should return correct suffix for responses', () => {
|
|
357
|
+
expect(getSemanticSuffix('responses')).toBe('Response')
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('should return correct suffix for requestBodies', () => {
|
|
361
|
+
expect(getSemanticSuffix('requestBodies')).toBe('Request')
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
describe('legacyResolve', () => {
|
|
366
|
+
test('should use original names without collision detection', () => {
|
|
367
|
+
const schemasWithMeta: SchemaWithMetadata[] = [
|
|
368
|
+
{
|
|
369
|
+
schema: { type: 'object' },
|
|
370
|
+
source: 'schemas',
|
|
371
|
+
originalName: 'User',
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
schema: { type: 'object' },
|
|
375
|
+
source: 'responses',
|
|
376
|
+
originalName: 'User',
|
|
377
|
+
},
|
|
378
|
+
]
|
|
379
|
+
|
|
380
|
+
const result = legacyResolve(schemasWithMeta)
|
|
381
|
+
|
|
382
|
+
// Last one wins (overwrites)
|
|
383
|
+
expect(Object.keys(result.schemas)).toEqual(['User'])
|
|
384
|
+
expect(result.nameMapping.get('#/components/responses/User')).toBe('User')
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
describe('resolveCollisions', () => {
|
|
389
|
+
test('should handle no collisions', () => {
|
|
390
|
+
const schemasWithMeta: SchemaWithMetadata[] = [
|
|
391
|
+
{
|
|
392
|
+
schema: { type: 'object' },
|
|
393
|
+
source: 'schemas',
|
|
394
|
+
originalName: 'User',
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
schema: { type: 'object' },
|
|
398
|
+
source: 'schemas',
|
|
399
|
+
originalName: 'Product',
|
|
400
|
+
},
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
const result = resolveCollisions(schemasWithMeta)
|
|
404
|
+
|
|
405
|
+
expect(Object.keys(result.schemas)).toEqual(['User', 'Product'])
|
|
406
|
+
expect(result.nameMapping.get('#/components/schemas/User')).toBe('User')
|
|
407
|
+
expect(result.nameMapping.get('#/components/schemas/Product')).toBe('Product')
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('should add semantic suffixes for cross-component collisions', () => {
|
|
411
|
+
const schemasWithMeta: SchemaWithMetadata[] = [
|
|
412
|
+
{
|
|
413
|
+
schema: { type: 'object', properties: { id: { type: 'string' } } },
|
|
414
|
+
source: 'schemas',
|
|
415
|
+
originalName: 'Order',
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
schema: { type: 'object', properties: { items: { type: 'array' } } },
|
|
419
|
+
source: 'requestBodies',
|
|
420
|
+
originalName: 'Order',
|
|
421
|
+
},
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
const result = resolveCollisions(schemasWithMeta)
|
|
425
|
+
|
|
426
|
+
expect(Object.keys(result.schemas)).toEqual(['OrderSchema', 'OrderRequest'])
|
|
427
|
+
expect(result.nameMapping.get('#/components/schemas/Order')).toBe('OrderSchema')
|
|
428
|
+
expect(result.nameMapping.get('#/components/requestBodies/Order')).toBe('OrderRequest')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('should add numeric suffixes for same-component collisions', () => {
|
|
432
|
+
const schemasWithMeta: SchemaWithMetadata[] = [
|
|
433
|
+
{
|
|
434
|
+
schema: { type: 'string', enum: ['A', 'B'] },
|
|
435
|
+
source: 'schemas',
|
|
436
|
+
originalName: 'Variant',
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
schema: { type: 'string', enum: ['X', 'Y'] },
|
|
440
|
+
source: 'schemas',
|
|
441
|
+
originalName: 'variant',
|
|
442
|
+
},
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
const result = resolveCollisions(schemasWithMeta)
|
|
446
|
+
|
|
447
|
+
expect(Object.keys(result.schemas)).toEqual(['Variant', 'variant2'])
|
|
448
|
+
expect(result.nameMapping.get('#/components/schemas/Variant')).toBe('Variant')
|
|
449
|
+
expect(result.nameMapping.get('#/components/schemas/variant')).toBe('variant2')
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('should handle case-insensitive collisions', () => {
|
|
453
|
+
const schemasWithMeta: SchemaWithMetadata[] = [
|
|
454
|
+
{
|
|
455
|
+
schema: { type: 'object', description: 'first' },
|
|
456
|
+
source: 'schemas',
|
|
457
|
+
originalName: 'User',
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
schema: { type: 'object', description: 'second' },
|
|
461
|
+
source: 'schemas',
|
|
462
|
+
originalName: 'user',
|
|
463
|
+
},
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
const result = resolveCollisions(schemasWithMeta)
|
|
467
|
+
|
|
468
|
+
// Should detect as collision and add numeric suffixes
|
|
469
|
+
expect(Object.keys(result.schemas)).toEqual(['User', 'user2'])
|
|
470
|
+
expect(result.schemas['User']).toBeDefined()
|
|
471
|
+
expect(result.schemas['user2']).toBeDefined()
|
|
472
|
+
})
|
|
473
|
+
})
|
|
187
474
|
})
|