@kubb/adapter-oas 5.0.0-alpha.7 → 5.0.0-alpha.70

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/refs.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { isReference } from './guards.ts'
2
+ import type { Document } from './types.ts'
3
+
4
+ /**
5
+ * Resolves a local JSON pointer reference from a document.
6
+ *
7
+ * Accepts `#/...` refs. Returns `null` for empty or non-local refs.
8
+ * Throws when the pointer cannot be resolved.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * resolveRef<SchemaObject>(document, '#/components/schemas/Pet') // SchemaObject | null
13
+ * ```
14
+ */
15
+ export function resolveRef<T = unknown>(document: Document, $ref: string): T | null {
16
+ const origRef = $ref
17
+ $ref = $ref.trim()
18
+ if ($ref === '') {
19
+ return null
20
+ }
21
+ if ($ref.startsWith('#')) {
22
+ $ref = globalThis.decodeURIComponent($ref.substring(1))
23
+ } else {
24
+ return null
25
+ }
26
+ const current = $ref
27
+ .split('/')
28
+ .filter(Boolean)
29
+ .reduce((obj: unknown, key: string) => (obj as Record<string, unknown>)?.[key], document as unknown)
30
+
31
+ if (!current) {
32
+ throw new Error(`Could not find a definition for ${origRef}.`)
33
+ }
34
+ return current as T
35
+ }
36
+
37
+ /**
38
+ * Resolves a `$ref` object while preserving the original `$ref` field on the result.
39
+ *
40
+ * Useful for parser flows that need both dereferenced fields and pointer
41
+ * identity (for naming/import purposes). Non-reference values are returned as-is.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * dereferenceWithRef(document, { $ref: '#/components/schemas/Pet' })
46
+ * // { $ref: '#/components/schemas/Pet', type: 'object', properties: { ... } }
47
+ * ```
48
+ */
49
+ export function dereferenceWithRef<T = unknown>(document: Document, schema?: T): T {
50
+ if (isReference(schema)) {
51
+ return {
52
+ ...schema,
53
+ ...resolveRef(document, schema.$ref),
54
+ $ref: schema.$ref,
55
+ }
56
+ }
57
+
58
+ return schema as T
59
+ }
@@ -0,0 +1,552 @@
1
+ import { pascalCase } from '@internals/utils'
2
+ import { ast } from '@kubb/core'
3
+ import type { ParameterObject, ServerObject } from 'oas/types'
4
+ import { isRef } from 'oas/types'
5
+ import { matchesMimeType } from 'oas/utils'
6
+ import { formatMap, SCHEMA_REF_PREFIX, structuralKeys } from './constants.ts'
7
+ import { isReference } from './guards.ts'
8
+ import { dereferenceWithRef, resolveRef } from './refs.ts'
9
+ import type { ContentType, Document, MediaTypeObject, Operation, ResponseObject, SchemaObject } from './types.ts'
10
+
11
+ /**
12
+ * Resolves `{variable}` placeholders in an OpenAPI server URL.
13
+ *
14
+ * Resolution order per variable: `overrides[key]` → `variable.default` → left unreplaced.
15
+ * Throws when an override value is not in the variable's allowed `enum` list.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * resolveServerUrl(
20
+ * { url: 'https://{env}.api.example.com', variables: { env: { default: 'dev', enum: ['dev', 'prod'] } } },
21
+ * { env: 'prod' },
22
+ * )
23
+ * // 'https://prod.api.example.com'
24
+ * ```
25
+ */
26
+ export function resolveServerUrl(server: ServerObject, overrides?: Record<string, string>): string {
27
+ if (!server.variables) {
28
+ return server.url
29
+ }
30
+
31
+ let url = server.url
32
+ for (const [key, variable] of Object.entries(server.variables)) {
33
+ const value = overrides?.[key] ?? (variable.default != null ? String(variable.default) : undefined)
34
+ if (value === undefined) {
35
+ continue
36
+ }
37
+
38
+ if (variable.enum?.length && !variable.enum.some((e) => String(e) === value)) {
39
+ throw new Error(`Invalid server variable value '${value}' for '${key}' when resolving ${server.url}. Valid values are: ${variable.enum.join(', ')}.`)
40
+ }
41
+
42
+ url = url.replaceAll(`{${key}}`, value)
43
+ }
44
+
45
+ return url
46
+ }
47
+
48
+ /**
49
+ * Looks up the Kubb `SchemaType` for a given OAS `format` string.
50
+ * Returns `undefined` for formats not in `formatMap` (e.g. `int64`, `date-time`),
51
+ * which are handled separately because their output depends on parser options.
52
+ */
53
+ export function getSchemaType(format: string): ast.SchemaType | null {
54
+ return formatMap[format as keyof typeof formatMap] ?? null
55
+ }
56
+
57
+ /**
58
+ * Maps an OAS primitive type string to its `PrimitiveSchemaType` equivalent.
59
+ * Numeric types (`number`, `integer`, `bigint`) are returned unchanged;
60
+ * `boolean` maps to `'boolean'`; everything else defaults to `'string'`.
61
+ */
62
+ export function getPrimitiveType(type: string | undefined): ast.PrimitiveSchemaType {
63
+ if (type === 'number' || type === 'integer' || type === 'bigint') return type
64
+ if (type === 'boolean') return 'boolean'
65
+
66
+ return 'string'
67
+ }
68
+
69
+ /**
70
+ * Narrows a raw content-type string to the `MediaType` union recognized by Kubb.
71
+ * Returns `undefined` for content types not present in `KNOWN_MEDIA_TYPES`.
72
+ */
73
+ export function getMediaType(contentType: string): ast.MediaType | null {
74
+ return Object.values(ast.mediaTypes).includes(contentType as ast.MediaType) ? (contentType as ast.MediaType) : null
75
+ }
76
+
77
+ export type OperationsOptions = {
78
+ contentType?: ContentType
79
+ }
80
+
81
+ /**
82
+ * Returns all resolved parameters for an operation, merging path-level and operation-level entries.
83
+ *
84
+ * Operation-level parameters take precedence over path-level ones with the same `in:name` key.
85
+ * `$ref` parameters are resolved via `dereferenceWithRef` to restore backward compatibility
86
+ * with `oas` v31+ which otherwise filters them out.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * getParameters(document, operation)
91
+ * // [{ name: 'petId', in: 'path', required: true, schema: { type: 'integer' } }]
92
+ * ```
93
+ */
94
+ export function getParameters(document: Document, operation: Operation): Array<ParameterObject> {
95
+ const resolveParams = (params: unknown[]): Array<ParameterObject> =>
96
+ params.map((p) => dereferenceWithRef(document, p)).filter((p): p is ParameterObject => !!p && typeof p === 'object' && 'in' in p && 'name' in p)
97
+
98
+ const operationParams = resolveParams(operation.schema?.parameters || [])
99
+ const pathItem = document.paths?.[operation.path]
100
+ const pathLevelParams = resolveParams(pathItem && !isReference(pathItem) && pathItem.parameters ? pathItem.parameters : [])
101
+
102
+ const paramMap = new Map<string, ParameterObject>()
103
+ for (const p of pathLevelParams) {
104
+ if (p.name && p.in) {
105
+ paramMap.set(`${p.in}:${p.name}`, p)
106
+ }
107
+ }
108
+ for (const p of operationParams) {
109
+ if (p.name && p.in) {
110
+ paramMap.set(`${p.in}:${p.name}`, p)
111
+ }
112
+ }
113
+
114
+ return Array.from(paramMap.values())
115
+ }
116
+
117
+ function getResponseBody(responseBody: boolean | ResponseObject, contentType?: string): MediaTypeObject | false | [string, MediaTypeObject, ...string[]] {
118
+ if (!responseBody) return false
119
+ if (isReference(responseBody)) return false
120
+
121
+ const body = responseBody as ResponseObject
122
+ if (!body.content) return false
123
+
124
+ if (contentType) {
125
+ if (!(contentType in body.content)) return false
126
+ return body.content[contentType]!
127
+ }
128
+
129
+ let availableContentType: string | undefined
130
+ const contentTypes = Object.keys(body.content)
131
+ for (const mt of contentTypes) {
132
+ if (matchesMimeType.json(mt)) {
133
+ availableContentType = mt
134
+ break
135
+ }
136
+ }
137
+
138
+ if (!availableContentType) {
139
+ availableContentType = contentTypes[0]
140
+ }
141
+
142
+ if (availableContentType) {
143
+ return [availableContentType, body.content[availableContentType]!, ...(body.description ? [body.description] : [])]
144
+ }
145
+
146
+ return false
147
+ }
148
+
149
+ /**
150
+ * Returns the response schema for a given operation and HTTP status code.
151
+ *
152
+ * Returns an empty object `{}` when no response body schema is available.
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * getResponseSchema(document, operation, 200) // SchemaObject
157
+ * getResponseSchema(document, operation, '4XX') // {}
158
+ * ```
159
+ */
160
+ export function getResponseSchema(document: Document, operation: Operation, statusCode: string | number, options: OperationsOptions = {}): SchemaObject {
161
+ if (operation.schema.responses) {
162
+ const responses = operation.schema.responses
163
+ for (const key in responses) {
164
+ const schema = responses[key]
165
+ if (schema && isReference(schema)) {
166
+ responses[key] = resolveRef<any>(document, schema.$ref)
167
+ }
168
+ }
169
+ }
170
+
171
+ const responseBody = getResponseBody(operation.getResponseByStatusCode(statusCode), options.contentType)
172
+
173
+ if (responseBody === false) {
174
+ return {}
175
+ }
176
+
177
+ const schema = Array.isArray(responseBody) ? responseBody[1].schema : responseBody.schema
178
+
179
+ if (!schema) {
180
+ return {}
181
+ }
182
+
183
+ return dereferenceWithRef(document, schema)
184
+ }
185
+
186
+ /**
187
+ * Returns the request body schema for an operation, or `undefined` when absent.
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * getRequestSchema(document, operation) // SchemaObject | null
192
+ * ```
193
+ */
194
+ export function getRequestSchema(document: Document, operation: Operation, options: OperationsOptions = {}): SchemaObject | null {
195
+ if (operation.schema.requestBody) {
196
+ operation.schema.requestBody = dereferenceWithRef(document, operation.schema.requestBody)
197
+ }
198
+
199
+ const requestBody = operation.getRequestBody(options.contentType)
200
+
201
+ if (requestBody === false) {
202
+ return null
203
+ }
204
+
205
+ const schema = Array.isArray(requestBody) ? requestBody[1].schema : requestBody.schema
206
+
207
+ if (!schema) {
208
+ return null
209
+ }
210
+
211
+ return dereferenceWithRef(document, schema)
212
+ }
213
+
214
+ /**
215
+ * The three component sections Kubb reads schemas from.
216
+ */
217
+ type SchemaSourceMode = 'schemas' | 'responses' | 'requestBodies'
218
+
219
+ /**
220
+ * A schema annotated with the component section it came from and its original name.
221
+ *
222
+ * Used during cross-source name-collision resolution in `resolveNameCollisions`.
223
+ */
224
+ export type SchemaWithMetadata = {
225
+ schema: SchemaObject
226
+ source: SchemaSourceMode
227
+ originalName: string
228
+ }
229
+
230
+ export type GetSchemasOptions = {
231
+ contentType?: ContentType
232
+ }
233
+
234
+ export type GetSchemasResult = {
235
+ schemas: Record<string, SchemaObject>
236
+ nameMapping: Map<string, string>
237
+ }
238
+
239
+ /**
240
+ * Flattens a keyword-only `allOf` into its parent schema.
241
+ *
242
+ * Only flattens when every member is a plain fragment — no `$ref` and no structural keywords
243
+ * (see `structuralKeys`). Outer schema values take precedence over fragment values.
244
+ * Returns `null` for a `null` input, and the original schema unchanged when flattening is unsafe.
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * flattenSchema({ allOf: [{ description: 'A pet' }], type: 'object', properties: {} })
249
+ * // { type: 'object', properties: {}, description: 'A pet' }
250
+ *
251
+ * flattenSchema({ allOf: [{ $ref: '#/components/schemas/Pet' }] })
252
+ * // returned unchanged — contains a $ref
253
+ * ```
254
+ */
255
+ /**
256
+ * Returns `true` when `fragment` carries any JSON Schema keyword that makes it
257
+ * structurally significant on its own (see `structuralKeys`).
258
+ *
259
+ * A fragment with a structural keyword can't be safely merged into a parent schema.
260
+ */
261
+ function hasStructuralKeywords(fragment: SchemaObject): boolean {
262
+ for (const key in fragment) {
263
+ if (structuralKeys.has(key as 'properties')) return true
264
+ }
265
+ return false
266
+ }
267
+
268
+ export function flattenSchema(schema: SchemaObject | null): SchemaObject | null {
269
+ if (!schema?.allOf || schema.allOf.length === 0) return schema ?? null
270
+
271
+ const allOfFragments = schema.allOf as SchemaObject[]
272
+ if (allOfFragments.some((item) => isRef(item))) return schema
273
+ if (allOfFragments.some(hasStructuralKeywords)) return schema
274
+
275
+ const merged: SchemaObject = { ...schema }
276
+ delete merged.allOf
277
+
278
+ for (const fragment of allOfFragments) {
279
+ for (const [key, value] of Object.entries(fragment)) {
280
+ if (merged[key as keyof typeof merged] === undefined) {
281
+ merged[key as keyof typeof merged] = value
282
+ }
283
+ }
284
+ }
285
+
286
+ return merged
287
+ }
288
+
289
+ /**
290
+ * Extracts the inline schema from a media-type `content` map.
291
+ *
292
+ * Prefers `preferredContentType` when given; otherwise uses the first key in the map.
293
+ * Returns `null` when `content` is absent, the schema is missing, or the schema is a `$ref`.
294
+ *
295
+ * @example
296
+ * ```ts
297
+ * extractSchemaFromContent(operation.content, 'application/json')
298
+ * // SchemaObject | null
299
+ * ```
300
+ */
301
+ export function extractSchemaFromContent(content: Record<string, unknown> | undefined, preferredContentType?: ContentType): SchemaObject | null {
302
+ if (!content) return null
303
+
304
+ const firstContentType = Object.keys(content)[0] ?? 'application/json'
305
+ const targetContentType = preferredContentType ?? firstContentType
306
+ const contentSchema = content[targetContentType] as { schema?: SchemaObject } | undefined
307
+ const schema = contentSchema?.schema
308
+
309
+ if (schema && '$ref' in schema) return null
310
+ return schema ?? null
311
+ }
312
+
313
+ /**
314
+ * Walks a schema tree and collects the names of all `#/components/schemas/<name>` `$ref`s.
315
+ */
316
+ function collectRefs(schema: unknown, refs = new Set<string>()): Set<string> {
317
+ if (Array.isArray(schema)) {
318
+ for (const item of schema) collectRefs(item, refs)
319
+ return refs
320
+ }
321
+
322
+ if (schema && typeof schema === 'object') {
323
+ for (const key in schema) {
324
+ const value = (schema as Record<string, unknown>)[key]
325
+ if (key === '$ref' && typeof value === 'string') {
326
+ if (value.startsWith(SCHEMA_REF_PREFIX)) {
327
+ const name = value.slice(SCHEMA_REF_PREFIX.length)
328
+ if (name) refs.add(name)
329
+ }
330
+ } else {
331
+ collectRefs(value, refs)
332
+ }
333
+ }
334
+ }
335
+
336
+ return refs
337
+ }
338
+
339
+ /**
340
+ * Returns a copy of `schemas` topologically sorted by `$ref` dependency.
341
+ *
342
+ * Referenced schemas appear before the schemas that depend on them, so code generators
343
+ * can emit types in the correct order. Cycles are silently skipped.
344
+ *
345
+ * @example
346
+ * ```ts
347
+ * const sorted = sortSchemas({ Order: orderSchema, Pet: petSchema })
348
+ * // Pet appears before Order when Order.$ref points at Pet
349
+ * ```
350
+ */
351
+ export function sortSchemas(schemas: Record<string, SchemaObject>): Record<string, SchemaObject> {
352
+ const deps = new Map<string, string[]>()
353
+
354
+ for (const [name, schema] of Object.entries(schemas)) {
355
+ deps.set(name, Array.from(collectRefs(schema)))
356
+ }
357
+
358
+ const sorted: string[] = []
359
+ const visited = new Set<string>()
360
+
361
+ function visit(name: string, stack: Set<string>) {
362
+ if (visited.has(name) || stack.has(name)) return
363
+ stack.add(name)
364
+ for (const child of deps.get(name) ?? []) {
365
+ if (deps.has(child)) visit(child, stack)
366
+ }
367
+ stack.delete(name)
368
+ visited.add(name)
369
+ sorted.push(name)
370
+ }
371
+
372
+ for (const name of Object.keys(schemas)) {
373
+ visit(name, new Set())
374
+ }
375
+
376
+ const result: Record<string, SchemaObject> = {}
377
+ for (const name of sorted) result[name] = schemas[name]!
378
+ return result
379
+ }
380
+
381
+ const semanticSuffixes: Record<SchemaSourceMode, string> = {
382
+ schemas: 'Schema',
383
+ responses: 'Response',
384
+ requestBodies: 'Request',
385
+ }
386
+
387
+ function getSemanticSuffix(source: SchemaSourceMode): string {
388
+ return semanticSuffixes[source]
389
+ }
390
+
391
+ function resolveSchemaRef(document: Document, schema: SchemaObject): SchemaObject {
392
+ if (!isReference(schema)) return schema
393
+ const resolved = resolveRef<SchemaObject>(document, schema.$ref)
394
+ return resolved && !isReference(resolved) ? resolved : schema
395
+ }
396
+
397
+ /**
398
+ * Collects component schemas from one or more sources and resolves name collisions.
399
+ *
400
+ * Sources default to `['schemas', 'requestBodies', 'responses']`. Returned schemas are
401
+ * topologically sorted by `$ref` dependency so generators emit types in the correct order.
402
+ *
403
+ * When two or more schemas normalize to the same PascalCase name:
404
+ * - Same source → numeric suffix (`2`, `3`, …).
405
+ * - Different sources → semantic suffix (`Schema`, `Response`, `Request`).
406
+ *
407
+ * @example
408
+ * ```ts
409
+ * const { schemas, nameMapping } = getSchemas(document, { contentType: 'application/json' })
410
+ * ```
411
+ */
412
+ export function getSchemas(document: Document, { contentType }: GetSchemasOptions): GetSchemasResult {
413
+ const components = document.components
414
+
415
+ const candidates: SchemaWithMetadata[] = [
416
+ ...Object.entries((components?.schemas as Record<string, SchemaObject>) ?? {}).map(([name, schema]) => ({
417
+ schema: resolveSchemaRef(document, schema),
418
+ source: 'schemas' as const,
419
+ originalName: name,
420
+ })),
421
+ ...(['responses', 'requestBodies'] as const).flatMap((source) =>
422
+ Object.entries(components?.[source] ?? {}).flatMap(([name, item]) => {
423
+ const schema = extractSchemaFromContent((item as { content?: Record<string, unknown> }).content, contentType)
424
+ return schema
425
+ ? [
426
+ {
427
+ schema: resolveSchemaRef(document, schema),
428
+ source,
429
+ originalName: name,
430
+ },
431
+ ]
432
+ : []
433
+ }),
434
+ ),
435
+ ]
436
+
437
+ const normalizedNames = new Map<string, SchemaWithMetadata[]>()
438
+ for (const item of candidates) {
439
+ const key = pascalCase(item.originalName)
440
+ const bucket = normalizedNames.get(key) ?? []
441
+ bucket.push(item)
442
+ normalizedNames.set(key, bucket)
443
+ }
444
+
445
+ const schemas: Record<string, SchemaObject> = {}
446
+ const nameMapping = new Map<string, string>()
447
+
448
+ for (const [, items] of normalizedNames) {
449
+ const isSingle = items.length === 1
450
+ let hasMultipleSources = false
451
+ if (!isSingle) {
452
+ const firstSource = items[0]!.source
453
+ for (let i = 1; i < items.length; i++) {
454
+ if (items[i]!.source !== firstSource) {
455
+ hasMultipleSources = true
456
+ break
457
+ }
458
+ }
459
+ }
460
+
461
+ items.forEach((item, index) => {
462
+ const suffix = isSingle ? '' : hasMultipleSources ? getSemanticSuffix(item.source) : index === 0 ? '' : String(index + 1)
463
+ const uniqueName = item.originalName + suffix
464
+ schemas[uniqueName] = item.schema
465
+ nameMapping.set(`#/components/${item.source}/${item.originalName}`, uniqueName)
466
+ })
467
+ }
468
+
469
+ return { schemas: sortSchemas(schemas), nameMapping }
470
+ }
471
+
472
+ /**
473
+ * Resolves the AST type descriptor for a date/time format, honoring the `dateType` option.
474
+ * Returns `null` when `dateType: false`, signalling the format should fall through to `string`.
475
+ */
476
+ export function getDateType(
477
+ options: ast.ParserOptions,
478
+ format: 'date-time' | 'date' | 'time',
479
+ ): { type: 'datetime'; offset?: boolean; local?: boolean } | { type: 'date' | 'time'; representation: 'date' | 'string' } | null {
480
+ if (!options.dateType) {
481
+ return null
482
+ }
483
+
484
+ if (format === 'date-time') {
485
+ if (options.dateType === 'date') {
486
+ return { type: 'date', representation: 'date' }
487
+ }
488
+ if (options.dateType === 'stringOffset') {
489
+ return { type: 'datetime', offset: true }
490
+ }
491
+ if (options.dateType === 'stringLocal') {
492
+ return { type: 'datetime', local: true }
493
+ }
494
+ return { type: 'datetime', offset: false }
495
+ }
496
+
497
+ if (format === 'date') {
498
+ return {
499
+ type: 'date',
500
+ representation: options.dateType === 'date' ? 'date' : 'string',
501
+ }
502
+ }
503
+
504
+ // time
505
+ return {
506
+ type: 'time',
507
+ representation: options.dateType === 'date' ? 'date' : 'string',
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Collects the shared metadata fields passed to every `createSchema` call.
513
+ */
514
+ export function buildSchemaNode(schema: SchemaObject, name: string | null | undefined, nullable: true | undefined, defaultValue: unknown) {
515
+ return {
516
+ name,
517
+ nullable,
518
+ title: schema.title,
519
+ description: schema.description,
520
+ deprecated: schema.deprecated,
521
+ readOnly: schema.readOnly,
522
+ writeOnly: schema.writeOnly,
523
+ default: defaultValue,
524
+ example: schema.example,
525
+ } as const
526
+ }
527
+
528
+ /**
529
+ * Returns all request body content type keys for an operation.
530
+ *
531
+ * The requestBody is dereferenced **in-place** when it is a `$ref` — the same mutation
532
+ * that `getRequestSchema` already performs — so that the returned list accurately reflects
533
+ * the available content types even for referenced bodies.
534
+ *
535
+ * @example
536
+ * ```ts
537
+ * getRequestBodyContentTypes(document, operation)
538
+ * // ['application/json', 'multipart/form-data']
539
+ * ```
540
+ */
541
+ export function getRequestBodyContentTypes(document: Document, operation: Operation): string[] {
542
+ if (operation.schema.requestBody) {
543
+ operation.schema.requestBody = dereferenceWithRef(document, operation.schema.requestBody)
544
+ }
545
+
546
+ const body = operation.schema.requestBody as { content?: Record<string, unknown> } | undefined
547
+ if (!body) return []
548
+
549
+ // dereferenceWithRef keeps $ref but spreads all resolved fields (including `content`).
550
+ // Do not bail out on isReference — the content is already present on the merged object.
551
+ return body.content ? Object.keys(body.content) : []
552
+ }