@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/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
+ }