@kubb/adapter-oas 5.0.0-beta.6 → 5.0.0-beta.60

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 CHANGED
@@ -1,6 +1,9 @@
1
+ import { type Diagnostic, Diagnostics } from '@kubb/core'
1
2
  import { isReference } from './guards.ts'
2
3
  import type { Document } from './types.ts'
3
4
 
5
+ const _refCache = new WeakMap<Document, Map<string, unknown>>()
6
+
4
7
  /**
5
8
  * Resolves a local JSON pointer reference from a document.
6
9
  *
@@ -18,19 +21,42 @@ export function resolveRef<T = unknown>(document: Document, $ref: string): T | n
18
21
  if ($ref === '') {
19
22
  return null
20
23
  }
21
- if ($ref.startsWith('#')) {
22
- $ref = globalThis.decodeURIComponent($ref.substring(1))
23
- } else {
24
- return null
24
+ if (!$ref.startsWith('#')) return null
25
+ $ref = globalThis.decodeURIComponent($ref.substring(1))
26
+
27
+ let docCache = _refCache.get(document)
28
+ if (!docCache) {
29
+ docCache = new Map()
30
+ _refCache.set(document, docCache)
31
+ }
32
+
33
+ if (docCache.has($ref)) {
34
+ return docCache.get($ref) as T
25
35
  }
36
+
26
37
  const current = $ref
27
38
  .split('/')
28
39
  .filter(Boolean)
29
40
  .reduce((obj: unknown, key: string) => (obj as Record<string, unknown>)?.[key], document as unknown)
30
41
 
31
42
  if (!current) {
32
- throw new Error(`Could not find a definition for ${origRef}.`)
43
+ const diagnostic: Diagnostic = {
44
+ code: Diagnostics.code.refNotFound,
45
+ severity: 'error',
46
+ message: `Could not find a definition for ${origRef}.`,
47
+ help: 'Add the schema under `components.schemas`, or fix the `$ref`. Run `kubb validate` to check the spec.',
48
+ location: { kind: 'schema', pointer: origRef, ref: origRef },
49
+ }
50
+ // Report the unresolved ref into the active build and resolve to null, like any
51
+ // other unresolvable ref. The build collects it and keeps going. Outside a build there is no
52
+ // sink, so throw rather than silently returning null.
53
+ if (!Diagnostics.report(diagnostic)) {
54
+ throw new Diagnostics.Error(diagnostic)
55
+ }
56
+ return null
33
57
  }
58
+
59
+ docCache.set($ref, current)
34
60
  return current as T
35
61
  }
36
62
 
package/src/resolvers.ts CHANGED
@@ -1,12 +1,12 @@
1
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'
2
+ import { Diagnostics } from '@kubb/core'
3
+ import type { ast } from '@kubb/core'
4
+ import { formatMap, SCHEMA_REF_PREFIX, specialCasedFormats, structuralKeys } from './constants.ts'
7
5
  import { isReference } from './guards.ts'
6
+ import { isJsonMimeType } from './mime.ts'
7
+ import { getRequestContent, getResponseByStatusCode } from './operation.ts'
8
8
  import { dereferenceWithRef, resolveRef } from './refs.ts'
9
- import type { ContentType, Document, MediaTypeObject, Operation, ResponseObject, SchemaObject } from './types.ts'
9
+ import type { ContentType, Document, MediaTypeObject, Operation, ParameterObject, ResponseObject, SchemaObject, ServerObject } from './types.ts'
10
10
 
11
11
  /**
12
12
  * Replaces `{variable}` placeholders in an OpenAPI server URL with provided values.
@@ -35,7 +35,13 @@ export function resolveServerUrl(server: ServerObject, overrides?: Record<string
35
35
  }
36
36
 
37
37
  if (variable.enum?.length && !variable.enum.some((e) => String(e) === value)) {
38
- throw new Error(`Invalid server variable value '${value}' for '${key}' when resolving ${server.url}. Valid values are: ${variable.enum.join(', ')}.`)
38
+ throw new Diagnostics.Error({
39
+ code: Diagnostics.code.invalidServerVariable,
40
+ severity: 'error',
41
+ message: `Invalid server variable value '${value}' for '${key}' when resolving ${server.url}. Valid values are: ${variable.enum.join(', ')}.`,
42
+ help: `Use one of the allowed enum values, or drop the enum on the '${key}' server variable.`,
43
+ location: { kind: 'document', pointer: '#/servers' },
44
+ })
39
45
  }
40
46
 
41
47
  url = url.replaceAll(`{${key}}`, value)
@@ -52,6 +58,16 @@ export function getSchemaType(format: string): ast.SchemaType | null {
52
58
  return formatMap[format as keyof typeof formatMap] ?? null
53
59
  }
54
60
 
61
+ /**
62
+ * Whether the parser maps `format` to a dedicated type. True for any `formatMap` entry and the
63
+ * `specialCasedFormats` `convertFormat` handles directly. False means the format falls back to the
64
+ * base type, which is what `KUBB_UNSUPPORTED_FORMAT` flags. Reading both sources keeps the
65
+ * diagnostic in step with the parser as `formatMap` grows.
66
+ */
67
+ export function isHandledFormat(format: string): boolean {
68
+ return getSchemaType(format) !== null || specialCasedFormats.has(format)
69
+ }
70
+
55
71
  /**
56
72
  * Converts an OAS primitive type string to its `PrimitiveSchemaType` equivalent.
57
73
  * Numeric types (`number`, `integer`, `bigint`) pass through unchanged. `boolean` maps to `'boolean'`. Everything else becomes `'string'`.
@@ -63,13 +79,6 @@ export function getPrimitiveType(type: string | undefined): ast.PrimitiveSchemaT
63
79
  return 'string'
64
80
  }
65
81
 
66
- /**
67
- * Narrows a content-type string to the `MediaType` union Kubb recognizes, or returns `null`.
68
- */
69
- export function getMediaType(contentType: string): ast.MediaType | null {
70
- return Object.values(ast.mediaTypes).includes(contentType as ast.MediaType) ? (contentType as ast.MediaType) : null
71
- }
72
-
73
82
  export type OperationsOptions = {
74
83
  contentType?: ContentType
75
84
  }
@@ -86,7 +95,7 @@ export type OperationsOptions = {
86
95
  * ```
87
96
  */
88
97
  export function getParameters(document: Document, operation: Operation): Array<ParameterObject> {
89
- const resolveParams = (params: unknown[]): Array<ParameterObject> =>
98
+ const resolveParams = (params: Array<unknown>): Array<ParameterObject> =>
90
99
  params.map((p) => dereferenceWithRef(document, p)).filter((p): p is ParameterObject => !!p && typeof p === 'object' && 'in' in p && 'name' in p)
91
100
 
92
101
  const operationParams = resolveParams(operation.schema?.parameters || [])
@@ -108,7 +117,7 @@ export function getParameters(document: Document, operation: Operation): Array<P
108
117
  return Array.from(paramMap.values())
109
118
  }
110
119
 
111
- function getResponseBody(responseBody: boolean | ResponseObject, contentType?: string): MediaTypeObject | false | [string, MediaTypeObject, ...string[]] {
120
+ function getResponseBody(responseBody: boolean | ResponseObject, contentType?: string): MediaTypeObject | false | [string, MediaTypeObject, ...Array<string>] {
112
121
  if (!responseBody) return false
113
122
  if (isReference(responseBody)) return false
114
123
 
@@ -123,7 +132,7 @@ function getResponseBody(responseBody: boolean | ResponseObject, contentType?: s
123
132
  let availableContentType: string | undefined
124
133
  const contentTypes = Object.keys(body.content)
125
134
  for (const mt of contentTypes) {
126
- if (matchesMimeType.json(mt)) {
135
+ if (isJsonMimeType(mt)) {
127
136
  availableContentType = mt
128
137
  break
129
138
  }
@@ -162,7 +171,7 @@ export function getResponseSchema(document: Document, operation: Operation, stat
162
171
  }
163
172
  }
164
173
 
165
- const responseBody = getResponseBody(operation.getResponseByStatusCode(statusCode), options.contentType)
174
+ const responseBody = getResponseBody(getResponseByStatusCode({ document, operation, statusCode }), options.contentType)
166
175
 
167
176
  if (responseBody === false) {
168
177
  return {}
@@ -190,7 +199,7 @@ export function getRequestSchema(document: Document, operation: Operation, optio
190
199
  operation.schema.requestBody = dereferenceWithRef(document, operation.schema.requestBody)
191
200
  }
192
201
 
193
- const requestBody = operation.getRequestBody(options.contentType)
202
+ const requestBody = getRequestContent({ document, operation, mediaType: options.contentType })
194
203
 
195
204
  if (requestBody === false) {
196
205
  return null
@@ -213,7 +222,7 @@ type SchemaSourceMode = 'schemas' | 'responses' | 'requestBodies'
213
222
  /**
214
223
  * A schema annotated with its component section source and original name. Used by `resolveNameCollisions` for cross-source collision resolution.
215
224
  */
216
- export type SchemaWithMetadata = {
225
+ type SchemaWithMetadata = {
217
226
  schema: SchemaObject
218
227
  source: SchemaSourceMode
219
228
  originalName: string
@@ -231,7 +240,7 @@ export type GetSchemasResult = {
231
240
  /**
232
241
  * Flattens a keyword-only `allOf` into its parent schema.
233
242
  *
234
- * Only flattens when every member is a plain fragment no `$ref` and no structural keywords
243
+ * Only flattens when every member is a plain fragment, with no `$ref` and no structural keywords
235
244
  * (see `structuralKeys`). Outer schema values take precedence over fragment values.
236
245
  * Returns `null` for a `null` input, and the original schema unchanged when flattening is unsafe.
237
246
  *
@@ -241,7 +250,7 @@ export type GetSchemasResult = {
241
250
  * // { type: 'object', properties: {}, description: 'A pet' }
242
251
  *
243
252
  * flattenSchema({ allOf: [{ $ref: '#/components/schemas/Pet' }] })
244
- * // returned unchanged contains a $ref
253
+ * // returned unchanged, contains a $ref
245
254
  * ```
246
255
  */
247
256
  /**
@@ -260,8 +269,8 @@ function hasStructuralKeywords(fragment: SchemaObject): boolean {
260
269
  export function flattenSchema(schema: SchemaObject | null): SchemaObject | null {
261
270
  if (!schema?.allOf || schema.allOf.length === 0) return schema ?? null
262
271
 
263
- const allOfFragments = schema.allOf as SchemaObject[]
264
- if (allOfFragments.some((item) => isRef(item))) return schema
272
+ const allOfFragments = schema.allOf as Array<SchemaObject>
273
+ if (allOfFragments.some((item) => isReference(item))) return schema
265
274
  if (allOfFragments.some(hasStructuralKeywords)) return schema
266
275
 
267
276
  const merged: SchemaObject = { ...schema }
@@ -281,7 +290,7 @@ export function flattenSchema(schema: SchemaObject | null): SchemaObject | null
281
290
  /**
282
291
  * Extracts the inline schema from a media-type `content` map.
283
292
  *
284
- * Prefers `preferredContentType` when given; otherwise uses the first key in the map.
293
+ * Prefers `preferredContentType` when given, otherwise uses the first key in the map.
285
294
  * Returns `null` when `content` is absent, the schema is missing, or the schema is a `$ref`.
286
295
  *
287
296
  * @example
@@ -305,27 +314,25 @@ export function extractSchemaFromContent(content: Record<string, unknown> | unde
305
314
  /**
306
315
  * Walks a schema tree and collects the names of all `#/components/schemas/<name>` `$ref`s.
307
316
  */
308
- function collectRefs(schema: unknown, refs = new Set<string>()): Set<string> {
317
+ function* collectRefs(schema: unknown): Generator<string, void, undefined> {
309
318
  if (Array.isArray(schema)) {
310
- for (const item of schema) collectRefs(item, refs)
311
- return refs
319
+ for (const item of schema) yield* collectRefs(item)
320
+ return
312
321
  }
313
322
 
314
323
  if (schema && typeof schema === 'object') {
315
324
  for (const key in schema) {
316
325
  const value = (schema as Record<string, unknown>)[key]
317
- if (key === '$ref' && typeof value === 'string') {
318
- if (value.startsWith(SCHEMA_REF_PREFIX)) {
319
- const name = value.slice(SCHEMA_REF_PREFIX.length)
320
- if (name) refs.add(name)
321
- }
322
- } else {
323
- collectRefs(value, refs)
326
+ if (!(key === '$ref' && typeof value === 'string')) {
327
+ yield* collectRefs(value)
328
+ continue
329
+ }
330
+ if (value.startsWith(SCHEMA_REF_PREFIX)) {
331
+ const name = value.slice(SCHEMA_REF_PREFIX.length)
332
+ if (name) yield name
324
333
  }
325
334
  }
326
335
  }
327
-
328
- return refs
329
336
  }
330
337
 
331
338
  /**
@@ -341,13 +348,13 @@ function collectRefs(schema: unknown, refs = new Set<string>()): Set<string> {
341
348
  * ```
342
349
  */
343
350
  export function sortSchemas(schemas: Record<string, SchemaObject>): Record<string, SchemaObject> {
344
- const deps = new Map<string, string[]>()
351
+ const deps = new Map<string, Array<string>>()
345
352
 
346
353
  for (const [name, schema] of Object.entries(schemas)) {
347
- deps.set(name, Array.from(collectRefs(schema)))
354
+ deps.set(name, [...new Set(collectRefs(schema))])
348
355
  }
349
356
 
350
- const sorted: string[] = []
357
+ const sorted: Array<string> = []
351
358
  const visited = new Set<string>()
352
359
 
353
360
  function visit(name: string, stack: Set<string>) {
@@ -404,7 +411,7 @@ function resolveSchemaRef(document: Document, schema: SchemaObject): SchemaObjec
404
411
  export function getSchemas(document: Document, { contentType }: GetSchemasOptions): GetSchemasResult {
405
412
  const components = document.components
406
413
 
407
- const candidates: SchemaWithMetadata[] = [
414
+ const candidates: Array<SchemaWithMetadata> = [
408
415
  ...Object.entries((components?.schemas as Record<string, SchemaObject>) ?? {}).map(([name, schema]) => ({
409
416
  schema: resolveSchemaRef(document, schema),
410
417
  source: 'schemas' as const,
@@ -426,7 +433,7 @@ export function getSchemas(document: Document, { contentType }: GetSchemasOption
426
433
  ),
427
434
  ]
428
435
 
429
- const normalizedNames = new Map<string, SchemaWithMetadata[]>()
436
+ const normalizedNames = new Map<string, Array<SchemaWithMetadata>>()
430
437
  for (const item of candidates) {
431
438
  const key = pascalCase(item.originalName)
432
439
  const bucket = normalizedNames.get(key) ?? []
@@ -463,7 +470,7 @@ export function getSchemas(document: Document, { contentType }: GetSchemasOption
463
470
 
464
471
  /**
465
472
  * Resolves the AST type descriptor for a date/time format, honoring the `dateType` option.
466
- * Returns `null` when `dateType: false`, signalling the format should fall through to `string`.
473
+ * Returns `null` when `dateType: false`, so the format falls through to `string`.
467
474
  */
468
475
  export function getDateType(
469
476
  options: ast.ParserOptions,
@@ -514,15 +521,16 @@ export function buildSchemaNode(schema: SchemaObject, name: string | null | unde
514
521
  writeOnly: schema.writeOnly,
515
522
  default: defaultValue,
516
523
  example: schema.example,
524
+ format: schema.format,
517
525
  } as const
518
526
  }
519
527
 
520
528
  /**
521
529
  * Returns all request body content type keys for an operation.
522
530
  *
523
- * The requestBody is dereferenced **in-place** when it is a `$ref` the same mutation
524
- * that `getRequestSchema` already performs so that the returned list accurately reflects
525
- * the available content types even for referenced bodies.
531
+ * The requestBody is dereferenced in place when it is a `$ref` (the same mutation that
532
+ * `getRequestSchema` already performs), so the returned list accurately reflects the
533
+ * available content types even for referenced bodies.
526
534
  *
527
535
  * @example
528
536
  * ```ts
@@ -530,7 +538,7 @@ export function buildSchemaNode(schema: SchemaObject, name: string | null | unde
530
538
  * // ['application/json', 'multipart/form-data']
531
539
  * ```
532
540
  */
533
- export function getRequestBodyContentTypes(document: Document, operation: Operation): string[] {
541
+ export function getRequestBodyContentTypes(document: Document, operation: Operation): Array<string> {
534
542
  if (operation.schema.requestBody) {
535
543
  operation.schema.requestBody = dereferenceWithRef(document, operation.schema.requestBody)
536
544
  }
@@ -539,6 +547,36 @@ export function getRequestBodyContentTypes(document: Document, operation: Operat
539
547
  if (!body) return []
540
548
 
541
549
  // dereferenceWithRef keeps $ref but spreads all resolved fields (including `content`).
542
- // Do not bail out on isReference the content is already present on the merged object.
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
+ }
553
+
554
+ /**
555
+ * Returns all response content type keys for an operation at a given status code.
556
+ *
557
+ * Response `$ref`s are resolved in place first (the same mutation `getResponseSchema` performs),
558
+ * so the returned list reflects the available content types even for referenced responses.
559
+ *
560
+ * @example
561
+ * ```ts
562
+ * getResponseBodyContentTypes(document, operation, 200)
563
+ * // ['application/json', 'application/xml']
564
+ * ```
565
+ */
566
+ export function getResponseBodyContentTypes(document: Document, operation: Operation, statusCode: string | number): Array<string> {
567
+ if (operation.schema.responses) {
568
+ const responses = operation.schema.responses
569
+ for (const key in responses) {
570
+ const schema = responses[key]
571
+ if (schema && isReference(schema)) {
572
+ responses[key] = resolveRef<any>(document, schema.$ref)
573
+ }
574
+ }
575
+ }
576
+
577
+ const responseObj = getResponseByStatusCode({ document, operation, statusCode })
578
+ if (!responseObj || typeof responseObj !== 'object' || isReference(responseObj)) return []
579
+
580
+ const body = responseObj as { content?: Record<string, unknown> }
543
581
  return body.content ? Object.keys(body.content) : []
544
582
  }
@@ -0,0 +1,76 @@
1
+ import { Diagnostics } from '@kubb/core'
2
+ import type { ast } from '@kubb/core'
3
+ import { isHandledFormat } from './resolvers.ts'
4
+
5
+ /**
6
+ * Reports the advisory diagnostics (`KUBB_UNSUPPORTED_FORMAT`, `KUBB_DEPRECATED`) for one
7
+ * top-level schema. Walks the node the parser produced during `preScan`, threading the RFC 6901
8
+ * pointer as it descends so a nested field reports against its full path
9
+ * (`#/components/schemas/Pet/properties/owner/properties/name`). Refs are not followed, so the
10
+ * resolved schema is reported under its own walk. Reports land in the active build run, are a
11
+ * no-op outside one, and repeats are deduped by the build.
12
+ */
13
+ export function reportSchemaDiagnostics({ node, name }: { node: ast.SchemaNode; name: string }): void {
14
+ visit(node, `#/components/schemas/${escapePointerToken(name)}`)
15
+ }
16
+
17
+ /**
18
+ * Escapes a single JSON pointer reference token per RFC 6901 (`~` → `~0`, `/` → `~1`), so a
19
+ * property name with those characters maps to a distinct pointer instead of colliding in the dedupe.
20
+ */
21
+ function escapePointerToken(token: string): string {
22
+ return token.replace(/~/g, '~0').replace(/\//g, '~1')
23
+ }
24
+
25
+ function visit(node: ast.SchemaNode, pointer: string): void {
26
+ if (node.deprecated) {
27
+ Diagnostics.report({
28
+ code: Diagnostics.code.deprecated,
29
+ severity: 'info',
30
+ message: 'This schema is marked as deprecated.',
31
+ location: { kind: 'schema', pointer },
32
+ })
33
+ }
34
+
35
+ if (typeof node.format === 'string' && !isHandledFormat(node.format)) {
36
+ Diagnostics.report({
37
+ code: Diagnostics.code.unsupportedFormat,
38
+ severity: 'warning',
39
+ message: `Kubb does not map the format "${node.format}" to a specific type, so it falls back to the base type.`,
40
+ help: `Use a format Kubb supports, or handle "${node.format}" with a custom parser or plugin.`,
41
+ location: { kind: 'schema', pointer },
42
+ })
43
+ }
44
+
45
+ if (node.type === 'object') {
46
+ for (const property of node.properties) {
47
+ visit(property.schema, `${pointer}/properties/${escapePointerToken(property.name)}`)
48
+ }
49
+ if (node.additionalProperties && typeof node.additionalProperties === 'object') {
50
+ visit(node.additionalProperties, `${pointer}/additionalProperties`)
51
+ }
52
+ return
53
+ }
54
+
55
+ if (node.type === 'array') {
56
+ for (const item of node.items ?? []) {
57
+ visit(item, `${pointer}/items`)
58
+ }
59
+ return
60
+ }
61
+
62
+ if (node.type === 'tuple') {
63
+ // Each tuple position has its own pointer, so index them. A shared `/items` would collapse
64
+ // distinct diagnostics in the dedupe.
65
+ for (const [index, item] of (node.items ?? []).entries()) {
66
+ visit(item, `${pointer}/items/${index}`)
67
+ }
68
+ return
69
+ }
70
+
71
+ if (node.type === 'union' || node.type === 'intersection') {
72
+ for (const [index, member] of (node.members ?? []).entries()) {
73
+ visit(member, `${pointer}/members/${index}`)
74
+ }
75
+ }
76
+ }