@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/src/utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import type { Config } from '@kubb/core'
|
|
3
|
+
import { pascalCase } from '@kubb/core/transformers'
|
|
3
4
|
import { URLPath } from '@kubb/core/utils'
|
|
4
5
|
import yaml from '@stoplight/yaml'
|
|
5
6
|
import type { ParameterObject, SchemaObject } from 'oas/types'
|
|
@@ -9,7 +10,7 @@ import type { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
|
|
|
9
10
|
import { isPlainObject, mergeDeep } from 'remeda'
|
|
10
11
|
import swagger2openapi from 'swagger2openapi'
|
|
11
12
|
import { Oas } from './Oas.ts'
|
|
12
|
-
import type { Document } from './types.ts'
|
|
13
|
+
import type { contentType, Document } from './types.ts'
|
|
13
14
|
|
|
14
15
|
export const STRUCTURAL_KEYS = new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf', 'not'])
|
|
15
16
|
|
|
@@ -36,9 +37,6 @@ export function isParameterObject(obj: ParameterObject | SchemaObject): obj is P
|
|
|
36
37
|
* Determines if a schema is nullable, considering:
|
|
37
38
|
* - OpenAPI 3.0 `nullable` / `x-nullable`
|
|
38
39
|
* - OpenAPI 3.1 JSON Schema `type: ['null', ...]` or `type: 'null'`
|
|
39
|
-
*
|
|
40
|
-
* @param schema - The schema object to check.
|
|
41
|
-
* @returns `true` if the schema is marked as nullable; otherwise, `false`.
|
|
42
40
|
*/
|
|
43
41
|
export function isNullable(schema?: SchemaObject & { 'x-nullable'?: boolean }): boolean {
|
|
44
42
|
const explicitNullable = schema?.nullable ?? schema?.['x-nullable']
|
|
@@ -59,8 +57,6 @@ export function isNullable(schema?: SchemaObject & { 'x-nullable'?: boolean }):
|
|
|
59
57
|
|
|
60
58
|
/**
|
|
61
59
|
* Determines if the given object is an OpenAPI ReferenceObject.
|
|
62
|
-
*
|
|
63
|
-
* @returns True if {@link obj} is a ReferenceObject; otherwise, false.
|
|
64
60
|
*/
|
|
65
61
|
export function isReference(obj?: any): obj is OpenAPIV3.ReferenceObject | OpenAPIV3_1.ReferenceObject {
|
|
66
62
|
return !!obj && isRef(obj)
|
|
@@ -68,8 +64,6 @@ export function isReference(obj?: any): obj is OpenAPIV3.ReferenceObject | OpenA
|
|
|
68
64
|
|
|
69
65
|
/**
|
|
70
66
|
* Determines if the given object is a SchemaObject with a discriminator property of type DiscriminatorObject.
|
|
71
|
-
*
|
|
72
|
-
* @returns True if {@link obj} is a SchemaObject containing a non-string {@link discriminator} property.
|
|
73
67
|
*/
|
|
74
68
|
export function isDiscriminator(obj?: any): obj is SchemaObject & { discriminator: OpenAPIV3.DiscriminatorObject } {
|
|
75
69
|
return !!obj && obj?.['discriminator'] && typeof obj.discriminator !== 'string'
|
|
@@ -79,9 +73,6 @@ export function isDiscriminator(obj?: any): obj is SchemaObject & { discriminato
|
|
|
79
73
|
* Determines whether a schema is required.
|
|
80
74
|
*
|
|
81
75
|
* Returns true if the schema has a non-empty {@link SchemaObject.required} array or a truthy {@link SchemaObject.required} property.
|
|
82
|
-
*
|
|
83
|
-
* @param schema - The schema object to check.
|
|
84
|
-
* @returns True if the schema is required; otherwise, false.
|
|
85
76
|
*/
|
|
86
77
|
export function isRequired(schema?: SchemaObject): boolean {
|
|
87
78
|
if (!schema) {
|
|
@@ -257,3 +248,260 @@ export function parseFromConfig(config: Config, oasClass: typeof Oas = Oas): Pro
|
|
|
257
248
|
|
|
258
249
|
return parse(path.resolve(config.root, config.input.path), { oasClass })
|
|
259
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Flatten allOf schemas by merging keyword-only fragments.
|
|
254
|
+
* Only flattens schemas where allOf items don't contain structural keys or $refs.
|
|
255
|
+
*/
|
|
256
|
+
export function flattenSchema(schema: SchemaObject | null): SchemaObject | null {
|
|
257
|
+
if (!schema?.allOf || schema.allOf.length === 0) {
|
|
258
|
+
return schema || null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Never touch ref-based or structural composition
|
|
262
|
+
if (schema.allOf.some((item) => isRef(item))) {
|
|
263
|
+
return schema
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const isPlainFragment = (item: SchemaObject) => !Object.keys(item).some((key) => STRUCTURAL_KEYS.has(key))
|
|
267
|
+
|
|
268
|
+
// Only flatten keyword-only fragments
|
|
269
|
+
if (!schema.allOf.every((item) => isPlainFragment(item as SchemaObject))) {
|
|
270
|
+
return schema
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const merged: SchemaObject = { ...schema }
|
|
274
|
+
delete merged.allOf
|
|
275
|
+
|
|
276
|
+
for (const fragment of schema.allOf as SchemaObject[]) {
|
|
277
|
+
for (const [key, value] of Object.entries(fragment)) {
|
|
278
|
+
if (merged[key as keyof typeof merged] === undefined) {
|
|
279
|
+
merged[key as keyof typeof merged] = value
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return merged
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Validate an OpenAPI document using oas-normalize.
|
|
289
|
+
*/
|
|
290
|
+
export async function validate(document: Document) {
|
|
291
|
+
const oasNormalize = new OASNormalize(document, {
|
|
292
|
+
enablePaths: true,
|
|
293
|
+
colorizeErrors: true,
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
return oasNormalize.validate({
|
|
297
|
+
parser: {
|
|
298
|
+
validate: {
|
|
299
|
+
errors: {
|
|
300
|
+
colorize: true,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
type SchemaSourceMode = 'schemas' | 'responses' | 'requestBodies'
|
|
308
|
+
|
|
309
|
+
export type SchemaWithMetadata = {
|
|
310
|
+
schema: SchemaObject
|
|
311
|
+
source: SchemaSourceMode
|
|
312
|
+
originalName: string
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
type GetSchemasResult = {
|
|
316
|
+
schemas: Record<string, SchemaObject>
|
|
317
|
+
nameMapping: Map<string, string>
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Collect all schema $ref dependencies recursively.
|
|
322
|
+
*/
|
|
323
|
+
export function collectRefs(schema: unknown, refs = new Set<string>()): Set<string> {
|
|
324
|
+
if (Array.isArray(schema)) {
|
|
325
|
+
for (const item of schema) {
|
|
326
|
+
collectRefs(item, refs)
|
|
327
|
+
}
|
|
328
|
+
return refs
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (schema && typeof schema === 'object') {
|
|
332
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
333
|
+
if (key === '$ref' && typeof value === 'string') {
|
|
334
|
+
const match = value.match(/^#\/components\/schemas\/(.+)$/)
|
|
335
|
+
if (match) {
|
|
336
|
+
refs.add(match[1]!)
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
collectRefs(value, refs)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return refs
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Sort schemas topologically so referenced schemas appear first.
|
|
349
|
+
*/
|
|
350
|
+
export function sortSchemas(schemas: Record<string, SchemaObject>): Record<string, SchemaObject> {
|
|
351
|
+
const deps = new Map<string, string[]>()
|
|
352
|
+
|
|
353
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
354
|
+
deps.set(name, Array.from(collectRefs(schema)))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const sorted: string[] = []
|
|
358
|
+
const visited = new Set<string>()
|
|
359
|
+
|
|
360
|
+
function visit(name: string, stack = new Set<string>()) {
|
|
361
|
+
if (visited.has(name)) {
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (stack.has(name)) {
|
|
365
|
+
return
|
|
366
|
+
} // circular refs, ignore
|
|
367
|
+
stack.add(name)
|
|
368
|
+
const children = deps.get(name) || []
|
|
369
|
+
for (const child of children) {
|
|
370
|
+
if (deps.has(child)) {
|
|
371
|
+
visit(child, stack)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
stack.delete(name)
|
|
375
|
+
visited.add(name)
|
|
376
|
+
sorted.push(name)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const name of Object.keys(schemas)) {
|
|
380
|
+
visit(name)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const sortedSchemas: Record<string, SchemaObject> = {}
|
|
384
|
+
for (const name of sorted) {
|
|
385
|
+
sortedSchemas[name] = schemas[name]!
|
|
386
|
+
}
|
|
387
|
+
return sortedSchemas
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Extract schema from content object (used by responses and requestBodies).
|
|
392
|
+
* Returns null if the schema is just a $ref (not a unique type definition).
|
|
393
|
+
*/
|
|
394
|
+
export function extractSchemaFromContent(content: Record<string, unknown> | undefined, preferredContentType?: contentType): SchemaObject | null {
|
|
395
|
+
if (!content) {
|
|
396
|
+
return null
|
|
397
|
+
}
|
|
398
|
+
const firstContentType = Object.keys(content)[0] || 'application/json'
|
|
399
|
+
const targetContentType = preferredContentType || firstContentType
|
|
400
|
+
const contentSchema = content[targetContentType] as { schema?: SchemaObject } | undefined
|
|
401
|
+
const schema = contentSchema?.schema
|
|
402
|
+
|
|
403
|
+
// Skip schemas that are just references - they don't define unique types
|
|
404
|
+
if (schema && '$ref' in schema) {
|
|
405
|
+
return null
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return schema || null
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Get semantic suffix for a schema source.
|
|
413
|
+
*/
|
|
414
|
+
export function getSemanticSuffix(source: SchemaSourceMode): string {
|
|
415
|
+
switch (source) {
|
|
416
|
+
case 'schemas':
|
|
417
|
+
return 'Schema'
|
|
418
|
+
case 'responses':
|
|
419
|
+
return 'Response'
|
|
420
|
+
case 'requestBodies':
|
|
421
|
+
return 'Request'
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Legacy resolution strategy - no collision detection, just use original names.
|
|
427
|
+
* This preserves backward compatibility when collisionDetection is false.
|
|
428
|
+
* @deprecated
|
|
429
|
+
*/
|
|
430
|
+
export function legacyResolve(schemasWithMeta: SchemaWithMetadata[]): GetSchemasResult {
|
|
431
|
+
const schemas: Record<string, SchemaObject> = {}
|
|
432
|
+
const nameMapping = new Map<string, string>()
|
|
433
|
+
|
|
434
|
+
// Simply use original names without collision detection
|
|
435
|
+
for (const item of schemasWithMeta) {
|
|
436
|
+
schemas[item.originalName] = item.schema
|
|
437
|
+
// Map using full $ref path for consistency
|
|
438
|
+
const refPath = `#/components/${item.source}/${item.originalName}`
|
|
439
|
+
nameMapping.set(refPath, item.originalName)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { schemas, nameMapping }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Resolve name collisions by applying suffixes based on collision type.
|
|
447
|
+
*
|
|
448
|
+
* Strategy:
|
|
449
|
+
* - Same-component collisions (e.g., "Variant" + "variant" both in schemas): numeric suffixes (Variant, Variant2)
|
|
450
|
+
* - Cross-component collisions (e.g., "Pet" in schemas + "Pet" in requestBodies): semantic suffixes (PetSchema, PetRequest)
|
|
451
|
+
*/
|
|
452
|
+
export function resolveCollisions(schemasWithMeta: SchemaWithMetadata[]): GetSchemasResult {
|
|
453
|
+
const schemas: Record<string, SchemaObject> = {}
|
|
454
|
+
const nameMapping = new Map<string, string>()
|
|
455
|
+
const normalizedNames = new Map<string, SchemaWithMetadata[]>()
|
|
456
|
+
|
|
457
|
+
// Group schemas by normalized (PascalCase) name for collision detection
|
|
458
|
+
for (const item of schemasWithMeta) {
|
|
459
|
+
const normalized = pascalCase(item.originalName)
|
|
460
|
+
if (!normalizedNames.has(normalized)) {
|
|
461
|
+
normalizedNames.set(normalized, [])
|
|
462
|
+
}
|
|
463
|
+
normalizedNames.get(normalized)!.push(item)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Process each collision group
|
|
467
|
+
for (const [, items] of normalizedNames) {
|
|
468
|
+
if (items.length === 1) {
|
|
469
|
+
// No collision, use original name
|
|
470
|
+
const item = items[0]!
|
|
471
|
+
schemas[item.originalName] = item.schema
|
|
472
|
+
// Map using full $ref path: #/components/{source}/{originalName}
|
|
473
|
+
const refPath = `#/components/${item.source}/${item.originalName}`
|
|
474
|
+
nameMapping.set(refPath, item.originalName)
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Multiple schemas normalize to same name - resolve collision
|
|
479
|
+
const sources = new Set(items.map((item) => item.source))
|
|
480
|
+
|
|
481
|
+
if (sources.size === 1) {
|
|
482
|
+
// Same-component collision: add numeric suffixes
|
|
483
|
+
// Preserve original order from OpenAPI spec for deterministic behavior
|
|
484
|
+
items.forEach((item, index) => {
|
|
485
|
+
const suffix = index === 0 ? '' : (index + 1).toString()
|
|
486
|
+
const uniqueName = item.originalName + suffix
|
|
487
|
+
schemas[uniqueName] = item.schema
|
|
488
|
+
// Map using full $ref path: #/components/{source}/{originalName}
|
|
489
|
+
const refPath = `#/components/${item.source}/${item.originalName}`
|
|
490
|
+
nameMapping.set(refPath, uniqueName)
|
|
491
|
+
})
|
|
492
|
+
} else {
|
|
493
|
+
// Cross-component collision: add semantic suffixes
|
|
494
|
+
// Preserve original order from OpenAPI spec for deterministic behavior
|
|
495
|
+
items.forEach((item) => {
|
|
496
|
+
const suffix = getSemanticSuffix(item.source)
|
|
497
|
+
const uniqueName = item.originalName + suffix
|
|
498
|
+
schemas[uniqueName] = item.schema
|
|
499
|
+
// Map using full $ref path: #/components/{source}/{originalName}
|
|
500
|
+
const refPath = `#/components/${item.source}/${item.originalName}`
|
|
501
|
+
nameMapping.set(refPath, uniqueName)
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { schemas, nameMapping }
|
|
507
|
+
}
|