UNPKG

@kubb/oas

Version:

OpenAPI Specification (OAS) utilities and helpers for Kubb, providing parsing, normalization, and manipulation of OpenAPI/Swagger schemas.

1 lines 71.2 kB
{"version":3,"file":"index.cjs","names":["#options","#transformParam","#eachParam","OASNormalize","yaml","path","BaseOas","#options","#applyDiscriminatorInheritance","#setDiscriminator","matchesMimeType","#getResponseBodyFactory"],"sources":["../src/constants.ts","../../../internals/utils/src/casing.ts","../../../internals/utils/src/reserved.ts","../../../internals/utils/src/urlPath.ts","../src/utils.ts","../src/Oas.ts","../src/resolveServerUrl.ts"],"sourcesContent":["import type { MediaType, SchemaType } from '@kubb/ast/types'\nimport type { HttpMethods as OASHttpMethods } from 'oas/types'\n\n/**\n * JSON Schema keywords that indicate structural composition.\n * Used when deciding whether an inline `allOf` fragment can be safely flattened\n * into its parent (fragments containing any of these keys must not be inlined).\n */\nexport const STRUCTURAL_KEYS = new Set<string>(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf', 'not'])\n\n/**\n * Maps OAS/JSON Schema `format` strings to their Kubb `SchemaType` equivalents.\n *\n * Only formats that require a type different from the raw OAS `type` are listed here.\n * `int64`, `date-time`, `date`, and `time` are handled separately because their\n * output depends on runtime parser options and cannot live in a static map.\n *\n * Note: `ipv4`, `ipv6`, and `hostname` map to `'url'` — not semantically accurate,\n * but `'url'` is the closest supported scalar type in the Kubb AST.\n */\nexport const FORMAT_MAP = {\n uuid: 'uuid',\n email: 'email',\n 'idn-email': 'email',\n uri: 'url',\n 'uri-reference': 'url',\n url: 'url',\n ipv4: 'url',\n ipv6: 'url',\n hostname: 'url',\n 'idn-hostname': 'url',\n binary: 'blob',\n byte: 'blob',\n // Numeric formats — format is more specific than type, so these override type.\n // see https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7\n int32: 'integer',\n float: 'number',\n double: 'number',\n} as const satisfies Record<string, SchemaType>\n\n/**\n * Exhaustive list of media types that Kubb recognizes.\n * Kept as a module-level constant to avoid re-allocating the array on every call.\n */\nexport const KNOWN_MEDIA_TYPES = [\n 'application/json',\n 'application/xml',\n 'application/x-www-form-urlencoded',\n 'application/octet-stream',\n 'application/pdf',\n 'application/zip',\n 'application/graphql',\n 'multipart/form-data',\n 'text/plain',\n 'text/html',\n 'text/csv',\n 'text/xml',\n 'image/png',\n 'image/jpeg',\n 'image/gif',\n 'image/webp',\n 'image/svg+xml',\n 'audio/mpeg',\n 'video/mp4',\n] as const satisfies ReadonlyArray<MediaType>\n\n/**\n * Vendor extension keys used by various spec generators to attach human-readable\n * labels to enum values. Checked in priority order: the first key found wins.\n */\nexport const ENUM_EXTENSION_KEYS = ['x-enumNames', 'x-enum-varnames'] as const\n\n/**\n * Canonical HTTP method names used throughout the Kubb OAS layer.\n * Keys are uppercase (as used in generated code); values are the lowercase\n * strings that the `oas` library uses internally.\n * @deprecated use httpMethods from @kubb/ast\n */\nexport const httpMethods = {\n GET: 'get',\n POST: 'post',\n PUT: 'put',\n PATCH: 'patch',\n DELETE: 'delete',\n HEAD: 'head',\n OPTIONS: 'options',\n TRACE: 'trace',\n} as const satisfies Record<Uppercase<OASHttpMethods>, OASHttpMethods>\n","type Options = {\n /** When `true`, dot-separated segments are split on `.` and joined with `/` after casing. */\n isFile?: boolean\n /** Text prepended before casing is applied. */\n prefix?: string\n /** Text appended before casing is applied. */\n suffix?: string\n}\n\n/**\n * Shared implementation for camelCase and PascalCase conversion.\n * Splits on common word boundaries (spaces, hyphens, underscores, dots, slashes, colons)\n * and capitalizes each word according to `pascal`.\n *\n * When `pascal` is `true` the first word is also capitalized (PascalCase), otherwise only subsequent words are.\n */\nfunction toCamelOrPascal(text: string, pascal: boolean): string {\n const normalized = text\n .trim()\n .replace(/([a-z\\d])([A-Z])/g, '$1 $2')\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')\n .replace(/(\\d)([a-z])/g, '$1 $2')\n\n const words = normalized.split(/[\\s\\-_./\\\\:]+/).filter(Boolean)\n\n return words\n .map((word, i) => {\n const allUpper = word.length > 1 && word === word.toUpperCase()\n if (allUpper) return word\n if (i === 0 && !pascal) return word.charAt(0).toLowerCase() + word.slice(1)\n return word.charAt(0).toUpperCase() + word.slice(1)\n })\n .join('')\n .replace(/[^a-zA-Z0-9]/g, '')\n}\n\n/**\n * Splits `text` on `.` and applies `transformPart` to each segment.\n * The last segment receives `isLast = true`, all earlier segments receive `false`.\n * Segments are joined with `/` to form a file path.\n */\nfunction applyToFileParts(text: string, transformPart: (part: string, isLast: boolean) => string): string {\n const parts = text.split('.')\n return parts.map((part, i) => transformPart(part, i === parts.length - 1)).join('/')\n}\n\n/**\n * Converts `text` to camelCase.\n * When `isFile` is `true`, dot-separated segments are each cased independently and joined with `/`.\n *\n * @example\n * camelCase('hello-world') // 'helloWorld'\n * camelCase('pet.petId', { isFile: true }) // 'pet/petId'\n */\nexport function camelCase(text: string, { isFile, prefix = '', suffix = '' }: Options = {}): string {\n if (isFile) {\n return applyToFileParts(text, (part, isLast) => camelCase(part, isLast ? { prefix, suffix } : {}))\n }\n\n return toCamelOrPascal(`${prefix} ${text} ${suffix}`, false)\n}\n\n/**\n * Converts `text` to PascalCase.\n * When `isFile` is `true`, the last dot-separated segment is PascalCased and earlier segments are camelCased.\n *\n * @example\n * pascalCase('hello-world') // 'HelloWorld'\n * pascalCase('pet.petId', { isFile: true }) // 'pet/PetId'\n */\nexport function pascalCase(text: string, { isFile, prefix = '', suffix = '' }: Options = {}): string {\n if (isFile) {\n return applyToFileParts(text, (part, isLast) => (isLast ? pascalCase(part, { prefix, suffix }) : camelCase(part)))\n }\n\n return toCamelOrPascal(`${prefix} ${text} ${suffix}`, true)\n}\n\n/**\n * Converts `text` to snake_case.\n *\n * @example\n * snakeCase('helloWorld') // 'hello_world'\n * snakeCase('Hello-World') // 'hello_world'\n */\nexport function snakeCase(text: string, { prefix = '', suffix = '' }: Omit<Options, 'isFile'> = {}): string {\n const processed = `${prefix} ${text} ${suffix}`.trim()\n return processed\n .replace(/([a-z])([A-Z])/g, '$1_$2')\n .replace(/[\\s\\-.]+/g, '_')\n .replace(/[^a-zA-Z0-9_]/g, '')\n .toLowerCase()\n .split('_')\n .filter(Boolean)\n .join('_')\n}\n\n/**\n * Converts `text` to SCREAMING_SNAKE_CASE.\n *\n * @example\n * screamingSnakeCase('helloWorld') // 'HELLO_WORLD'\n */\nexport function screamingSnakeCase(text: string, { prefix = '', suffix = '' }: Omit<Options, 'isFile'> = {}): string {\n return snakeCase(text, { prefix, suffix }).toUpperCase()\n}\n","/**\n * JavaScript and Java reserved words.\n * @link https://github.com/jonschlinkert/reserved/blob/master/index.js\n */\nconst reservedWords = [\n 'abstract',\n 'arguments',\n 'boolean',\n 'break',\n 'byte',\n 'case',\n 'catch',\n 'char',\n 'class',\n 'const',\n 'continue',\n 'debugger',\n 'default',\n 'delete',\n 'do',\n 'double',\n 'else',\n 'enum',\n 'eval',\n 'export',\n 'extends',\n 'false',\n 'final',\n 'finally',\n 'float',\n 'for',\n 'function',\n 'goto',\n 'if',\n 'implements',\n 'import',\n 'in',\n 'instanceof',\n 'int',\n 'interface',\n 'let',\n 'long',\n 'native',\n 'new',\n 'null',\n 'package',\n 'private',\n 'protected',\n 'public',\n 'return',\n 'short',\n 'static',\n 'super',\n 'switch',\n 'synchronized',\n 'this',\n 'throw',\n 'throws',\n 'transient',\n 'true',\n 'try',\n 'typeof',\n 'var',\n 'void',\n 'volatile',\n 'while',\n 'with',\n 'yield',\n 'Array',\n 'Date',\n 'hasOwnProperty',\n 'Infinity',\n 'isFinite',\n 'isNaN',\n 'isPrototypeOf',\n 'length',\n 'Math',\n 'name',\n 'NaN',\n 'Number',\n 'Object',\n 'prototype',\n 'String',\n 'toString',\n 'undefined',\n 'valueOf',\n]\n\n/**\n * Prefixes a word with `_` when it is a reserved JavaScript/Java identifier\n * or starts with a digit.\n */\nexport function transformReservedWord(word: string): string {\n const firstChar = word.charCodeAt(0)\n if (word && (reservedWords.includes(word) || (firstChar >= 48 && firstChar <= 57))) {\n return `_${word}`\n }\n return word\n}\n\n/**\n * Returns `true` when `name` is a syntactically valid JavaScript variable name.\n */\nexport function isValidVarName(name: string): boolean {\n try {\n new Function(`var ${name}`)\n } catch {\n return false\n }\n return true\n}\n","import { camelCase } from './casing.ts'\nimport { isValidVarName } from './reserved.ts'\n\nexport type URLObject = {\n /** The resolved URL string (Express-style or template literal, depending on context). */\n url: string\n /** Extracted path parameters as a key-value map, or `undefined` when the path has none. */\n params?: Record<string, string>\n}\n\ntype ObjectOptions = {\n /** Controls whether the `url` is rendered as an Express path or a template literal. Defaults to `'path'`. */\n type?: 'path' | 'template'\n /** Optional transform applied to each extracted parameter name. */\n replacer?: (pathParam: string) => string\n /** When `true`, the result is serialized to a string expression instead of a plain object. */\n stringify?: boolean\n}\n\n/** Supported identifier casing strategies for path parameters. */\ntype PathCasing = 'camelcase'\n\ntype Options = {\n /** Casing strategy applied to path parameter names. Defaults to the original identifier. */\n casing?: PathCasing\n}\n\n/**\n * Parses and transforms an OpenAPI/Swagger path string into various URL formats.\n *\n * @example\n * const p = new URLPath('/pet/{petId}')\n * p.URL // '/pet/:petId'\n * p.template // '`/pet/${petId}`'\n */\nexport class URLPath {\n /** The raw OpenAPI/Swagger path string, e.g. `/pet/{petId}`. */\n path: string\n\n #options: Options\n\n constructor(path: string, options: Options = {}) {\n this.path = path\n this.#options = options\n }\n\n /** Converts the OpenAPI path to Express-style colon syntax, e.g. `/pet/{petId}` → `/pet/:petId`. */\n get URL(): string {\n return this.toURLPath()\n }\n\n /** Returns `true` when `path` is a fully-qualified URL (e.g. starts with `https://`). */\n get isURL(): boolean {\n try {\n return !!new URL(this.path).href\n } catch {\n return false\n }\n }\n\n /**\n * Converts the OpenAPI path to a TypeScript template literal string.\n *\n * @example\n * new URLPath('/pet/{petId}').template // '`/pet/${petId}`'\n * new URLPath('/account/monetary-accountID').template // '`/account/${monetaryAccountId}`'\n */\n get template(): string {\n return this.toTemplateString()\n }\n\n /** Returns the path and its extracted params as a structured `URLObject`, or as a stringified expression when `stringify` is set. */\n get object(): URLObject | string {\n return this.toObject()\n }\n\n /** Returns a map of path parameter names, or `undefined` when the path has no parameters. */\n get params(): Record<string, string> | undefined {\n return this.getParams()\n }\n\n #transformParam(raw: string): string {\n const param = isValidVarName(raw) ? raw : camelCase(raw)\n return this.#options.casing === 'camelcase' ? camelCase(param) : param\n }\n\n /** Iterates over every `{param}` token in `path`, calling `fn` with the raw token and transformed name. */\n #eachParam(fn: (raw: string, param: string) => void): void {\n for (const match of this.path.matchAll(/\\{([^}]+)\\}/g)) {\n const raw = match[1]!\n fn(raw, this.#transformParam(raw))\n }\n }\n\n toObject({ type = 'path', replacer, stringify }: ObjectOptions = {}): URLObject | string {\n const object = {\n url: type === 'path' ? this.toURLPath() : this.toTemplateString({ replacer }),\n params: this.getParams(),\n }\n\n if (stringify) {\n if (type === 'template') {\n return JSON.stringify(object).replaceAll(\"'\", '').replaceAll(`\"`, '')\n }\n\n if (object.params) {\n return `{ url: '${object.url}', params: ${JSON.stringify(object.params).replaceAll(\"'\", '').replaceAll(`\"`, '')} }`\n }\n\n return `{ url: '${object.url}' }`\n }\n\n return object\n }\n\n /**\n * Converts the OpenAPI path to a TypeScript template literal string.\n * An optional `replacer` can transform each extracted parameter name before interpolation.\n *\n * @example\n * new URLPath('/pet/{petId}').toTemplateString() // '`/pet/${petId}`'\n */\n toTemplateString({ prefix = '', replacer }: { prefix?: string; replacer?: (pathParam: string) => string } = {}): string {\n const parts = this.path.split(/\\{([^}]+)\\}/)\n const result = parts\n .map((part, i) => {\n if (i % 2 === 0) return part\n const param = this.#transformParam(part)\n return `\\${${replacer ? replacer(param) : param}}`\n })\n .join('')\n\n return `\\`${prefix}${result}\\``\n }\n\n /**\n * Extracts all `{param}` segments from the path and returns them as a key-value map.\n * An optional `replacer` transforms each parameter name in both key and value positions.\n * Returns `undefined` when no path parameters are found.\n */\n getParams(replacer?: (pathParam: string) => string): Record<string, string> | undefined {\n const params: Record<string, string> = {}\n\n this.#eachParam((_raw, param) => {\n const key = replacer ? replacer(param) : param\n params[key] = key\n })\n\n return Object.keys(params).length > 0 ? params : undefined\n }\n\n /** Converts the OpenAPI path to Express-style colon syntax, e.g. `/pet/{petId}` → `/pet/:petId`. */\n toURLPath(): string {\n return this.path.replace(/\\{([^}]+)\\}/g, ':$1')\n }\n}\n","import path from 'node:path'\nimport { pascalCase, URLPath } from '@internals/utils'\nimport type { Config } from '@kubb/core'\nimport { bundle, loadConfig } from '@redocly/openapi-core'\nimport yaml from '@stoplight/yaml'\nimport type { ParameterObject } from 'oas/types'\nimport { isRef, isSchema } from 'oas/types'\nimport OASNormalize from 'oas-normalize'\nimport type { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'\nimport { isPlainObject, mergeDeep } from 'remeda'\nimport swagger2openapi from 'swagger2openapi'\nimport { STRUCTURAL_KEYS } from './constants.ts'\nimport { Oas } from './Oas.ts'\nimport type { contentType, Document, SchemaObject } from './types.ts'\n\n/**\n * Returns `true` when `doc` looks like a Swagger 2.0 document (no `openapi` key).\n */\nexport function isOpenApiV2Document(doc: unknown): doc is OpenAPIV2.Document {\n return !!doc && isPlainObject(doc) && !('openapi' in doc)\n}\n\n/**\n * Returns `true` when `doc` looks like an OpenAPI 3.x document (has `openapi` key).\n */\nexport function isOpenApiV3Document(doc: unknown): doc is OpenAPIV3.Document {\n return !!doc && isPlainObject(doc) && 'openapi' in doc\n}\n\n/**\n * Returns `true` when `doc` is an OpenAPI 3.1 document.\n */\nexport function isOpenApiV3_1Document(doc: unknown): doc is OpenAPIV3_1.Document {\n return !!doc && isPlainObject(doc) && 'openapi' in (doc as object) && (doc as { openapi: string }).openapi.startsWith('3.1')\n}\n\n/**\n * Returns `true` when `obj` is a JSON Schema object recognized by the `oas` library.\n */\nexport function isJSONSchema(obj?: unknown): obj is SchemaObject {\n return !!obj && isSchema(obj)\n}\n\n/**\n * Returns `true` when `obj` is a parameter object (has an `in` field distinguishing it from a schema).\n */\nexport function isParameterObject(obj: ParameterObject | SchemaObject): obj is ParameterObject {\n return !!obj && 'in' in obj\n}\n\n/**\n * Determines if a schema is nullable, considering:\n * - OpenAPI 3.0 `nullable` / `x-nullable`\n * - OpenAPI 3.1 JSON Schema `type: ['null', ...]` or `type: 'null'`\n */\nexport function isNullable(schema?: SchemaObject & { 'x-nullable'?: boolean }): boolean {\n const explicitNullable = schema?.nullable ?? schema?.['x-nullable']\n if (explicitNullable === true) {\n return true\n }\n\n const schemaType = schema?.type\n if (schemaType === 'null') {\n return true\n }\n if (Array.isArray(schemaType)) {\n return schemaType.includes('null')\n }\n\n return false\n}\n\n/**\n * Returns `true` when `obj` is an OpenAPI `$ref` pointer object.\n */\nexport function isReference(obj?: unknown): obj is OpenAPIV3.ReferenceObject | OpenAPIV3_1.ReferenceObject {\n return !!obj && isRef(obj as object)\n}\n\n/**\n * Returns `true` when `obj` is a schema that carries a structured `discriminator` object\n * (as opposed to a plain string discriminator used in some older specs).\n */\nexport function isDiscriminator(obj?: unknown): obj is SchemaObject & { discriminator: OpenAPIV3.DiscriminatorObject } {\n const record = obj as Record<string, unknown>\n return !!obj && !!record['discriminator'] && typeof record['discriminator'] !== 'string'\n}\n\n/**\n * Determines whether a schema is required.\n *\n * Returns true if the schema has a non-empty {@link SchemaObject.required} array or a truthy {@link SchemaObject.required} property.\n */\nexport function isRequired(schema?: SchemaObject): boolean {\n if (!schema) {\n return false\n }\n\n return Array.isArray(schema.required) ? !!schema.required?.length : !!schema.required\n}\n\n// Helper to determine if a schema (and its composed children) has no required fields\n// This prefers structural optionality over top-level optional flag\ntype JSONSchemaLike =\n | {\n required?: readonly string[]\n allOf?: readonly unknown[]\n anyOf?: readonly unknown[]\n oneOf?: readonly unknown[]\n }\n | undefined\n\n//TODO make isAllOptional more like isOptional with better typings\nexport function isAllOptional(schema: unknown): boolean {\n // If completely absent, consider it optional in context of defaults\n if (!schema) return true\n // If the entire schema itself is optional, it's safe to default\n if (isOptional(schema)) return true\n\n const s = schema as JSONSchemaLike\n const hasRequired = Array.isArray(s?.required) && s?.required.length > 0\n if (hasRequired) return false\n\n const groups = [s?.allOf, s?.anyOf, s?.oneOf].filter((g): g is readonly unknown[] => Array.isArray(g))\n if (groups.length === 0) return true\n\n // Be conservative: only when all composed parts are all-optional we treat it as all-optional\n return groups.every((arr) => arr.every((child) => isAllOptional(child)))\n}\n\nexport function isOptional(schema?: SchemaObject): boolean {\n return !isRequired(schema)\n}\n\n/**\n * Determines the appropriate default value for a schema parameter.\n * - For array types: returns '[]'\n * - For union types (anyOf/oneOf):\n * - If at least one variant has all-optional fields: returns '{}'\n * - Otherwise: returns undefined (no default)\n * - For object types with optional fields: returns '{}'\n * - For primitive types (string, number, boolean): returns undefined (no default)\n * - For required types: returns undefined (no default)\n */\nexport function getDefaultValue(schema?: SchemaObject): string | undefined {\n if (!schema || !isOptional(schema)) {\n return undefined\n }\n\n // For array types, use empty array as default\n if (schema.type === 'array') {\n return '[]'\n }\n\n // For union types (anyOf/oneOf), check if any variant could accept an empty object\n if (schema.anyOf || schema.oneOf) {\n const variants = schema.anyOf || schema.oneOf\n if (!Array.isArray(variants)) {\n return undefined\n }\n // Only provide default if at least one variant has all-optional fields\n const hasEmptyObjectVariant = variants.some((variant) => isAllOptional(variant))\n if (!hasEmptyObjectVariant) {\n return undefined\n }\n // At least one variant accepts empty object\n return '{}'\n }\n\n // For object types (or schemas with properties), use empty object as default\n // This is safe because we already checked isOptional above\n if (schema.type === 'object' || schema.properties) {\n return '{}'\n }\n\n // For other types (primitives like string, number, boolean), no default\n return undefined\n}\n\nexport async function parse(\n pathOrApi: string | Document,\n { oasClass = Oas, canBundle = true, enablePaths = true }: { oasClass?: typeof Oas; canBundle?: boolean; enablePaths?: boolean } = {},\n): Promise<Oas> {\n if (typeof pathOrApi === 'string' && canBundle) {\n // resolve external refs\n const config = await loadConfig()\n const bundleResults = await bundle({ ref: pathOrApi, config, base: pathOrApi })\n\n return parse(bundleResults.bundle.parsed as string, { oasClass, canBundle, enablePaths })\n }\n\n const oasNormalize = new OASNormalize(pathOrApi, {\n enablePaths,\n colorizeErrors: true,\n })\n const document = (await oasNormalize.load()) as Document\n\n if (isOpenApiV2Document(document)) {\n const { openapi } = await swagger2openapi.convertObj(document, {\n anchors: true,\n })\n\n return new oasClass(openapi as Document)\n }\n\n return new oasClass(document)\n}\n\nexport async function merge(pathOrApi: Array<string | Document>, { oasClass = Oas }: { oasClass?: typeof Oas } = {}): Promise<Oas> {\n const instances = await Promise.all(pathOrApi.map((p) => parse(p, { oasClass, enablePaths: false, canBundle: false })))\n\n if (instances.length === 0) {\n throw new Error('No OAS instances provided for merging.')\n }\n\n const merged = instances.reduce(\n (acc, current) => {\n return mergeDeep(acc, current.document as Document)\n },\n {\n openapi: '3.0.0',\n info: {\n title: 'Merged API',\n version: '1.0.0',\n },\n paths: {},\n components: {\n schemas: {},\n },\n } as any,\n )\n\n return parse(merged, { oasClass })\n}\n\nexport function parseFromConfig(config: Config, oasClass: typeof Oas = Oas): Promise<Oas> {\n if ('data' in config.input) {\n if (typeof config.input.data === 'object') {\n const api: Document = structuredClone(config.input.data) as Document\n return parse(api, { oasClass })\n }\n\n // data is a string - try YAML first, then fall back to passing to parse()\n try {\n const api: string = yaml.parse(config.input.data as string)\n return parse(api, { oasClass })\n } catch (_e) {\n // YAML parse failed, let parse() handle it (supports JSON strings and more)\n return parse(config.input.data as string, { oasClass })\n }\n }\n\n if (Array.isArray(config.input)) {\n return merge(\n config.input.map((input) => path.resolve(config.root, input.path)),\n { oasClass },\n )\n }\n\n if (new URLPath(config.input.path).isURL) {\n return parse(config.input.path, { oasClass })\n }\n\n return parse(path.resolve(config.root, config.input.path), { oasClass })\n}\n\n/**\n * Flatten allOf schemas by merging keyword-only fragments.\n * Only flattens schemas where allOf items don't contain structural keys or $refs.\n */\nexport function flattenSchema(schema: SchemaObject | null): SchemaObject | null {\n if (!schema?.allOf || schema.allOf.length === 0) {\n return schema || null\n }\n\n // Never touch ref-based or structural composition\n if (schema.allOf.some((item) => isRef(item))) {\n return schema\n }\n\n const isPlainFragment = (item: SchemaObject) => !Object.keys(item).some((key) => STRUCTURAL_KEYS.has(key))\n\n // Only flatten keyword-only fragments\n if (!schema.allOf.every((item) => isPlainFragment(item as SchemaObject))) {\n return schema\n }\n\n const merged: SchemaObject = { ...schema }\n delete merged.allOf\n\n for (const fragment of schema.allOf as SchemaObject[]) {\n for (const [key, value] of Object.entries(fragment)) {\n if (merged[key as keyof typeof merged] === undefined) {\n merged[key as keyof typeof merged] = value\n }\n }\n }\n\n return merged\n}\n\n/**\n * Validate an OpenAPI document using oas-normalize.\n */\nexport async function validate(document: Document) {\n const oasNormalize = new OASNormalize(document, {\n enablePaths: true,\n colorizeErrors: true,\n })\n\n return oasNormalize.validate({\n parser: {\n validate: {\n errors: {\n colorize: true,\n },\n },\n },\n })\n}\n\ntype SchemaSourceMode = 'schemas' | 'responses' | 'requestBodies'\n\nexport type SchemaWithMetadata = {\n schema: SchemaObject\n source: SchemaSourceMode\n originalName: string\n}\n\ntype GetSchemasResult = {\n schemas: Record<string, SchemaObject>\n nameMapping: Map<string, string>\n}\n\n/**\n * Collect all schema $ref dependencies recursively.\n */\nexport function collectRefs(schema: unknown, refs = new Set<string>()): Set<string> {\n if (Array.isArray(schema)) {\n for (const item of schema) {\n collectRefs(item, refs)\n }\n return refs\n }\n\n if (schema && typeof schema === 'object') {\n for (const [key, value] of Object.entries(schema)) {\n if (key === '$ref' && typeof value === 'string') {\n const match = value.match(/^#\\/components\\/schemas\\/(.+)$/)\n if (match) {\n refs.add(match[1]!)\n }\n } else {\n collectRefs(value, refs)\n }\n }\n }\n\n return refs\n}\n\n/**\n * Sort schemas topologically so referenced schemas appear first.\n */\nexport function sortSchemas(schemas: Record<string, SchemaObject>): Record<string, SchemaObject> {\n const deps = new Map<string, string[]>()\n\n for (const [name, schema] of Object.entries(schemas)) {\n deps.set(name, Array.from(collectRefs(schema)))\n }\n\n const sorted: string[] = []\n const visited = new Set<string>()\n\n function visit(name: string, stack = new Set<string>()) {\n if (visited.has(name)) {\n return\n }\n if (stack.has(name)) {\n return\n } // circular refs, ignore\n stack.add(name)\n const children = deps.get(name) || []\n for (const child of children) {\n if (deps.has(child)) {\n visit(child, stack)\n }\n }\n stack.delete(name)\n visited.add(name)\n sorted.push(name)\n }\n\n for (const name of Object.keys(schemas)) {\n visit(name)\n }\n\n const sortedSchemas: Record<string, SchemaObject> = {}\n for (const name of sorted) {\n sortedSchemas[name] = schemas[name]!\n }\n return sortedSchemas\n}\n\n/**\n * Extract schema from content object (used by responses and requestBodies).\n * Returns null if the schema is just a $ref (not a unique type definition).\n */\nexport function extractSchemaFromContent(content: Record<string, unknown> | undefined, preferredContentType?: contentType): SchemaObject | null {\n if (!content) {\n return null\n }\n const firstContentType = Object.keys(content)[0] || 'application/json'\n const targetContentType = preferredContentType || firstContentType\n const contentSchema = content[targetContentType] as { schema?: SchemaObject } | undefined\n const schema = contentSchema?.schema\n\n // Skip schemas that are just references - they don't define unique types\n if (schema && '$ref' in schema) {\n return null\n }\n\n return schema || null\n}\n\n/**\n * Get semantic suffix for a schema source.\n */\nexport function getSemanticSuffix(source: SchemaSourceMode): string {\n switch (source) {\n case 'schemas':\n return 'Schema'\n case 'responses':\n return 'Response'\n case 'requestBodies':\n return 'Request'\n }\n}\n\n/**\n * Legacy resolution strategy - no collision detection, just use original names.\n * This preserves backward compatibility when collisionDetection is false.\n * @deprecated\n */\nexport function legacyResolve(schemasWithMeta: SchemaWithMetadata[]): GetSchemasResult {\n const schemas: Record<string, SchemaObject> = {}\n const nameMapping = new Map<string, string>()\n\n // Simply use original names without collision detection\n for (const item of schemasWithMeta) {\n schemas[item.originalName] = item.schema\n // Map using full $ref path for consistency\n const refPath = `#/components/${item.source}/${item.originalName}`\n nameMapping.set(refPath, item.originalName)\n }\n\n return { schemas, nameMapping }\n}\n\n/**\n * Resolve name collisions by applying suffixes based on collision type.\n *\n * Strategy:\n * - Same-component collisions (e.g., \"Variant\" + \"variant\" both in schemas): numeric suffixes (Variant, Variant2)\n * - Cross-component collisions (e.g., \"Pet\" in schemas + \"Pet\" in requestBodies): semantic suffixes (PetSchema, PetRequest)\n */\nexport function resolveCollisions(schemasWithMeta: SchemaWithMetadata[]): GetSchemasResult {\n const schemas: Record<string, SchemaObject> = {}\n const nameMapping = new Map<string, string>()\n const normalizedNames = new Map<string, SchemaWithMetadata[]>()\n\n // Group schemas by normalized (PascalCase) name for collision detection\n for (const item of schemasWithMeta) {\n const normalized = pascalCase(item.originalName)\n if (!normalizedNames.has(normalized)) {\n normalizedNames.set(normalized, [])\n }\n normalizedNames.get(normalized)!.push(item)\n }\n\n // Process each collision group\n for (const [, items] of normalizedNames) {\n if (items.length === 1) {\n // No collision, use original name\n const item = items[0]!\n schemas[item.originalName] = item.schema\n // Map using full $ref path: #/components/{source}/{originalName}\n const refPath = `#/components/${item.source}/${item.originalName}`\n nameMapping.set(refPath, item.originalName)\n continue\n }\n\n // Multiple schemas normalize to same name - resolve collision\n const sources = new Set(items.map((item) => item.source))\n\n if (sources.size === 1) {\n // Same-component collision: add numeric suffixes\n // Preserve original order from OpenAPI spec for deterministic behavior\n items.forEach((item, index) => {\n const suffix = index === 0 ? '' : (index + 1).toString()\n const uniqueName = item.originalName + suffix\n schemas[uniqueName] = item.schema\n // Map using full $ref path: #/components/{source}/{originalName}\n const refPath = `#/components/${item.source}/${item.originalName}`\n nameMapping.set(refPath, uniqueName)\n })\n } else {\n // Cross-component collision: add semantic suffixes\n // Preserve original order from OpenAPI spec for deterministic behavior\n items.forEach((item) => {\n const suffix = getSemanticSuffix(item.source)\n const uniqueName = item.originalName + suffix\n schemas[uniqueName] = item.schema\n // Map using full $ref path: #/components/{source}/{originalName}\n const refPath = `#/components/${item.source}/${item.originalName}`\n nameMapping.set(refPath, uniqueName)\n })\n }\n }\n\n return { schemas, nameMapping }\n}\n","import jsonpointer from 'jsonpointer'\nimport BaseOas from 'oas'\nimport type { ParameterObject } from 'oas/types'\nimport { matchesMimeType } from 'oas/utils'\nimport type { contentType, DiscriminatorObject, Document, MediaTypeObject, Operation, ReferenceObject, ResponseObject, SchemaObject } from './types.ts'\nimport {\n extractSchemaFromContent,\n flattenSchema,\n isDiscriminator,\n isReference,\n legacyResolve,\n resolveCollisions,\n type SchemaWithMetadata,\n sortSchemas,\n validate,\n} from './utils.ts'\n\n/**\n * Prefix used to create synthetic `$ref` values for anonymous (inline) discriminator schemas.\n * The suffix is the schema index within the discriminator's `oneOf`/`anyOf` array.\n * @example `#kubb-inline-0`\n */\nexport const KUBB_INLINE_REF_PREFIX = '#kubb-inline-'\n\ntype OasOptions = {\n contentType?: contentType\n discriminator?: 'strict' | 'inherit'\n /**\n * Resolve name collisions when schemas from different components share the same name (case-insensitive).\n * @default false\n */\n collisionDetection?: boolean\n}\n\nexport class Oas extends BaseOas {\n #options: OasOptions = {\n discriminator: 'strict',\n }\n document: Document\n\n constructor(document: Document) {\n super(document, undefined)\n\n this.document = document\n }\n\n setOptions(options: OasOptions) {\n this.#options = {\n ...this.#options,\n ...options,\n }\n\n if (this.#options.discriminator === 'inherit') {\n this.#applyDiscriminatorInheritance()\n }\n }\n\n get options(): OasOptions {\n return this.#options\n }\n\n get<T = unknown>($ref: string): T | null {\n const origRef = $ref\n $ref = $ref.trim()\n if ($ref === '') {\n return null\n }\n if ($ref.startsWith('#')) {\n $ref = globalThis.decodeURIComponent($ref.substring(1))\n } else {\n return null\n }\n const current = jsonpointer.get(this.api, $ref)\n\n if (!current) {\n throw new Error(`Could not find a definition for ${origRef}.`)\n }\n return current as T\n }\n\n getKey($ref: string) {\n const key = $ref.split('/').pop()\n return key === '' ? undefined : key\n }\n set($ref: string, value: unknown) {\n $ref = $ref.trim()\n if ($ref === '') {\n return false\n }\n if ($ref.startsWith('#')) {\n $ref = globalThis.decodeURIComponent($ref.substring(1))\n\n jsonpointer.set(this.api, $ref, value)\n }\n }\n\n #setDiscriminator(schema: SchemaObject & { discriminator: DiscriminatorObject }): void {\n const { mapping = {}, propertyName } = schema.discriminator\n\n if (this.#options.discriminator === 'inherit') {\n Object.entries(mapping).forEach(([mappingKey, mappingValue]) => {\n if (mappingValue) {\n const childSchema = this.get<any>(mappingValue)\n if (!childSchema) {\n return\n }\n\n if (!childSchema.properties) {\n childSchema.properties = {}\n }\n\n const property = childSchema.properties[propertyName] as SchemaObject\n\n if (childSchema.properties) {\n childSchema.properties[propertyName] = {\n ...((childSchema.properties ? childSchema.properties[propertyName] : {}) as SchemaObject),\n enum: [...(property?.enum?.filter((value) => value !== mappingKey) ?? []), mappingKey],\n }\n\n childSchema.required =\n typeof childSchema.required === 'boolean' ? childSchema.required : [...new Set([...(childSchema.required ?? []), propertyName])]\n\n this.set(mappingValue, childSchema)\n }\n }\n })\n }\n }\n\n getDiscriminator(schema: SchemaObject | null): DiscriminatorObject | null {\n if (!isDiscriminator(schema) || !schema) {\n return null\n }\n\n const { mapping = {}, propertyName } = schema.discriminator\n\n /**\n * Helper to extract discriminator value from a schema.\n * Checks in order:\n * 1. Extension property matching propertyName (e.g., x-linode-ref-name)\n * 2. Property with const value\n * 3. Property with single enum value\n * 4. Title as fallback\n */\n const getDiscriminatorValue = (schema: SchemaObject | null): string | null => {\n if (!schema) {\n return null\n }\n\n // Check extension properties first (e.g., x-linode-ref-name)\n // Only check if propertyName starts with 'x-' to avoid conflicts with standard properties\n if (propertyName.startsWith('x-')) {\n const extensionValue = (schema as Record<string, unknown>)[propertyName]\n if (extensionValue && typeof extensionValue === 'string') {\n return extensionValue\n }\n }\n\n // Check if property has const value\n const propertySchema = schema.properties?.[propertyName] as SchemaObject\n if (propertySchema && 'const' in propertySchema && propertySchema.const !== undefined) {\n return String(propertySchema.const)\n }\n\n // Check if property has single enum value\n if (propertySchema && propertySchema.enum?.length === 1) {\n return String(propertySchema.enum[0])\n }\n\n // Fallback to title if available\n return schema.title || null\n }\n\n /**\n * Process oneOf/anyOf items to build mapping.\n * Handles both $ref and inline schemas.\n */\n const processSchemas = (schemas: Array<SchemaObject>, existingMapping: Record<string, string>) => {\n schemas.forEach((schemaItem, index) => {\n if (isReference(schemaItem)) {\n // Handle $ref case\n const key = this.getKey(schemaItem.$ref)\n\n try {\n const refSchema = this.get<SchemaObject>(schemaItem.$ref)\n const discriminatorValue = getDiscriminatorValue(refSchema)\n const canAdd = key && !Object.values(existingMapping).includes(schemaItem.$ref)\n\n if (canAdd && discriminatorValue) {\n existingMapping[discriminatorValue] = schemaItem.$ref\n } else if (canAdd) {\n existingMapping[key] = schemaItem.$ref\n }\n } catch (_error) {\n // If we can't resolve the reference, skip it and use the key as fallback\n if (key && !Object.values(existingMapping).includes(schemaItem.$ref)) {\n existingMapping[key] = schemaItem.$ref\n }\n }\n } else {\n // Handle inline schema case\n const inlineSchema = schemaItem as SchemaObject\n const discriminatorValue = getDiscriminatorValue(inlineSchema)\n\n if (discriminatorValue) {\n // Create a synthetic ref for inline schemas using index\n // The value points to the inline schema itself via a special marker\n existingMapping[discriminatorValue] = `${KUBB_INLINE_REF_PREFIX}${index}`\n }\n }\n })\n }\n\n // Process oneOf schemas\n if (schema.oneOf) {\n processSchemas(schema.oneOf as Array<SchemaObject>, mapping)\n }\n\n // Process anyOf schemas\n if (schema.anyOf) {\n processSchemas(schema.anyOf as Array<SchemaObject>, mapping)\n }\n\n return {\n ...schema.discriminator,\n mapping,\n }\n }\n\n // TODO add better typing\n dereferenceWithRef<T = unknown>(schema?: T): T {\n if (isReference(schema)) {\n return {\n ...schema,\n ...this.get(schema.$ref),\n $ref: schema.$ref,\n }\n }\n\n return schema as T\n }\n\n #applyDiscriminatorInheritance() {\n const components = this.api.components\n if (!components?.schemas) {\n return\n }\n\n const visited = new WeakSet<object>()\n const enqueue = (value: unknown) => {\n if (!value) {\n return\n }\n\n if (Array.isArray(value)) {\n for (const item of value) {\n enqueue(item)\n }\n return\n }\n\n if (typeof value === 'object') {\n visit(value as SchemaObject)\n }\n }\n\n const visit = (schema?: SchemaObject | ReferenceObject | null) => {\n if (!schema || typeof schema !== 'object') {\n return\n }\n\n if (isReference(schema)) {\n visit(this.get(schema.$ref) as SchemaObject)\n return\n }\n\n const schemaObject = schema as SchemaObject\n\n if (visited.has(schemaObject as object)) {\n return\n }\n\n visited.add(schemaObject as object)\n\n if (isDiscriminator(schemaObject)) {\n this.#setDiscriminator(schemaObject)\n }\n\n if ('allOf' in schemaObject) {\n enqueue(schemaObject.allOf)\n }\n if ('oneOf' in schemaObject) {\n enqueue(schemaObject.oneOf)\n }\n if ('anyOf' in schemaObject) {\n enqueue(schemaObject.anyOf)\n }\n if ('not' in schemaObject) {\n enqueue(schemaObject.not)\n }\n if ('items' in schemaObject) {\n enqueue(schemaObject.items)\n }\n if ('prefixItems' in schemaObject) {\n enqueue(schemaObject.prefixItems)\n }\n\n if (schemaObject.properties) {\n enqueue(Object.values(schemaObject.properties))\n }\n\n if (schemaObject.additionalProperties && typeof schemaObject.additionalProperties === 'object') {\n enqueue(schemaObject.additionalProperties)\n }\n }\n\n for (const schema of Object.values(components.schemas)) {\n visit(schema as SchemaObject)\n }\n }\n\n /**\n * Oas does not have a getResponseBody(contentType)\n */\n #getResponseBodyFactory(responseBody: boolean | ResponseObject): (contentType?: string) => MediaTypeObject | false | [string, MediaTypeObject, ...string[]] {\n function hasResponseBody(res = responseBody): res is ResponseObject {\n return !!res\n }\n\n return (contentType) => {\n if (!hasResponseBody(responseBody)) {\n return false\n }\n\n if (isReference(responseBody)) {\n // If the request body is still a `$ref` pointer we should return false because this library\n // assumes that you've run dereferencing beforehand.\n return false\n }\n\n if (!responseBody.content) {\n return false\n }\n\n if (contentType) {\n if (!(contentType in responseBody.content)) {\n return false\n }\n\n return responseBody.content[contentType]!\n }\n\n // Since no media type was supplied we need to find either the first JSON-like media type that\n // we've got, or the first available of anything else if no JSON-like media types are present.\n let availableContentType: string | undefined\n const contentTypes = Object.keys(responseBody.content)\n contentTypes.forEach((mt: string) => {\n if (!availableContentType && matchesMimeType.json(mt)) {\n availableContentType = mt\n }\n })\n\n if (!availableContentType) {\n contentTypes.forEach((mt: string) => {\n if (!availableContentType) {\n availableContentType = mt\n }\n })\n }\n\n if (availableContentType) {\n return [availableContentType, responseBody.content[availableContentType]!, ...(responseBody.description ? [responseBody.description] : [])]\n }\n\n return false\n }\n }\n\n getResponseSchema(operation: Operation, statusCode: string | number): SchemaObject {\n if (operation.schema.responses) {\n Object.keys(operation.schema.responses).forEach((key) => {\n const schema = operation.schema.responses![key]\n const $ref = isReference(schema) ? schema.$ref : undefined\n\n if (schema && $ref) {\n operation.schema.responses![key] = this.get<any>($ref)\n }\n })\n }\n\n const getResponseBody = this.#getResponseBodyFactory(operation.getResponseByStatusCode(statusCode))\n\n const { contentType } = this.#options\n const responseBody = getResponseBody(contentType)\n\n if (responseBody === false) {\n // return empty object because response will always be defined(request does not need a body)\n return {}\n }\n\n const schema = Array.isArray(responseBody) ? responseBody[1].schema : responseBody.schema\n\n if (!schema) {\n // return empty object because response will always be defined(request does not need a body)\n\n return {}\n }\n\n return this.dereferenceWithRef(schema)\n }\n\n getRequestSchema(operation: Operation): SchemaObject | undefined {\n const { contentType } = this.#options\n\n if (operation.schema.requestBody) {\n operation.schema.requestBody = this.dereferenceWithRef(operation.schema.requestBody)\n }\n\n const requestBody = operation.getRequestBody(contentType)\n\n if (requestBody === false) {\n return undefined\n }\n\n const schema = Array.isArray(requestBody) ? requestBody[1].schema : requestBody.schema\n\n if (!schema) {\n return undefined\n }\n\n return this.dereferenceWithRef(schema)\n }\n\n getParametersSchema(operation: Operation, inKey: 'path' | 'query' | 'header'): SchemaObject | null {\n const { contentType = operation.getContentType() } = this.#options\n\n // Collect parameters from both operation-level and path-level, resolving $ref pointers.\n // oas v31+ filters out $ref parameters in getParameters(), so we access raw parameters\n // directly and resolve refs ourselves to preserve backward compatibility.\n // Note: dereferenceWithRef preserves the $ref property on resolved objects, so we check\n // for 'in' and 'name' fields to validate successful resolution instead of !isReference().\n const resolveParams = (params: unknown[]): Array<ParameterObject> =>\n params.map((p) => this.dereferenceWithRef(p)).filter((p): p is ParameterObject => !!p && typeof p === 'object' && 'in' in p && 'name' in p)\n\n const operationParams = resolveParams(operation.schema?.parameters || [])\n const pathItem = this.api?.paths?.[operation.path]\n const pathLevelParams = resolveParams(pathItem && !isReference(pathItem) && pathItem.parameters ? pathItem.parameters : [])\n\n // Deduplicate: operation-level parameters override path-level ones with the same name+in\n const paramMap = new Map<string, ParameterObject>()\n for (const p of pathLevelParams) {\n if (p.name && p.in) {\n paramMap.set(`${p.in}:${p.name}`, p)\n }\n }\n for (const p of operationParams) {\n if (p.name && p.in) {\n paramMap.set(`${p.in}:${p.name}`, p)\n }\n }\n\n const params = Array.from(paramMap.values()).filter((v) => v.in === inKey)\n\n if (!params.length) {\n return null\n }\n\n return params.reduce(\n (schema, pathParameters) => {\n const property = (pathParameters.content?.[contentType]?.schema ?? (pathParameters.schema as SchemaObject)) as SchemaObject | null\n const required =\n typeof schema.required === 'boolean'\n ? schema.required\n : [...(schema.required || []), pathParameters.required ? pathParameters.name : undefined].filter(Boolean)\n\n // Handle explode=true with style=form for object with additionalProperties\n // According to OpenAPI spec, when explode is true, object properties are flattened\n const getDefaultStyle = (location: string): string => {\n if (location === 'query') return 'form'\n if (location === 'path') return 'simple'\n return 'simple'\n }\n const style = pathParameters.style || getDefaultStyle(inKey)\n const explode = pathParameters.explode !== undefined ? pathParameters.explode : style === 'form'\n\n if (\n inKey === 'query' &&\n style === 'form' &&\n explode === true &&\n property?.type === 'object' &&\n property?.additionalProperties &&\n !property?.properties\n ) {\n // When explode is true for an object with only additionalProperties,\n // flatten it to the root level by merging additionalProperties with existing schema.\n // This preserves other query parameters while allowing dynamic key-value pairs.\n return {\n ...schema,\n description: pathParameters.description || schema.description,\n deprecated: schema.deprecated,\n example: property.example || schema.example,\n additionalProperties: property.additionalProperties,\n } as SchemaObject\n }\n\n return {\n ...schema,\n description: schema.description,\n deprecated: schema.deprecated,\n example: schema.example,\n required,\n properties: {\n ...schema.properties,\n [pathParameters.name]: {\n description: pathParameters.description,\n ...property,\n },\n },\n } as SchemaObject\n },\n { type: 'object', required: [], properties: {} } as SchemaObject,\n )\n }\n\n async validate() {\n return validate(this.api)\n }\n\n flattenSchema(schema: SchemaObject | null): SchemaObject | null {\n return flattenSchema(schema)\n }\n\n /**\n * Get schemas from OpenAPI components (schemas, responses, requestBodies).\n * Returns schemas in dependency order along with name mapping for collision resolution.\n */\n getSchemas(options: { contentType?: contentType; includes?: Array<'schemas' | 'responses' | 'requestBodies'>; collisionDetection?: boolean } = {}): {\n schemas: Record<string, SchemaObject>\n nameMapping: Map<string, string>\n } {\n const contentType = options.contentType ?? this.#options.contentType\n const includes = options.includes ?? ['schemas', 'requestBodies', 'responses']\n const shouldResolveCollisions = options.collisionDetection ?? this.#options.collisionDetection ?? false\n\n const components = this.getDefinition().components\n const schemasWithMeta: SchemaWithMetadata[] = []\n\n // Collect schemas from components\n if (includes.includes('schemas')) {\n const componentSchemas = (components?.schemas as Record<string, SchemaObject>) || {}\n for (const [name, schemaObject] of Object.entries(componentSchemas)) {\n // Resolve schema if it's a $ref (can happen when the bundler deduplicates schemas\n // referenced from multiple external files). Without this, a $ref schema would be\n // parsed as a reference to itself, generating `z.