@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubb/oas",
3
- "version": "4.18.5",
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.7",
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.18.5"
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 { isDiscriminator, isReference, STRUCTURAL_KEYS } from './utils.ts'
7
-
8
- type Options = {
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: 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: 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(): 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
- const OASNormalize = await import('oas-normalize').then((m) => m.default)
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
- if (!schema?.allOf || schema.allOf.length === 0) {
502
- return schema || null
503
- }
501
+ return flattenSchema(schema)
502
+ }
504
503
 
505
- // Never touch ref-based or structural composition
506
- if (schema.allOf.some((item) => isReference(item))) {
507
- return schema
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
- const isPlainFragment = (item: SchemaObject) => !Object.keys(item).some((key) => STRUCTURAL_KEYS.has(key))
511
-
512
- // Only flatten keyword-only fragments
513
- if (!schema.allOf.every((item) => isPlainFragment(item as SchemaObject))) {
514
- return schema
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
- const merged: SchemaObject = { ...schema }
518
- delete merged.allOf
519
-
520
- for (const fragment of schema.allOf as SchemaObject[]) {
521
- for (const [key, value] of Object.entries(fragment)) {
522
- if (merged[key as keyof typeof merged] === undefined) {
523
- merged[key as keyof typeof merged] = value
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
- return merged
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
@@ -14,4 +14,5 @@ export {
14
14
  merge,
15
15
  parse,
16
16
  parseFromConfig,
17
+ validate,
17
18
  } from './utils.ts'
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 { merge, parse, parseFromConfig } from './utils.ts'
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
  })