UNPKG

@kubb/oas

Version:

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

523 lines (450 loc) • 16.6 kB
import path from 'node:path' import { pascalCase, URLPath } from '@internals/utils' import type { Config } from '@kubb/core' import { bundle, loadConfig } from '@redocly/openapi-core' import yaml from '@stoplight/yaml' import type { ParameterObject } from 'oas/types' import { isRef, isSchema } from 'oas/types' import OASNormalize from 'oas-normalize' import type { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' import { isPlainObject, mergeDeep } from 'remeda' import swagger2openapi from 'swagger2openapi' import { STRUCTURAL_KEYS } from './constants.ts' import { Oas } from './Oas.ts' import type { contentType, Document, SchemaObject } from './types.ts' /** * Returns `true` when `doc` looks like a Swagger 2.0 document (no `openapi` key). */ export function isOpenApiV2Document(doc: unknown): doc is OpenAPIV2.Document { return !!doc && isPlainObject(doc) && !('openapi' in doc) } /** * Returns `true` when `doc` looks like an OpenAPI 3.x document (has `openapi` key). */ export function isOpenApiV3Document(doc: unknown): doc is OpenAPIV3.Document { return !!doc && isPlainObject(doc) && 'openapi' in doc } /** * Returns `true` when `doc` is an OpenAPI 3.1 document. */ export function isOpenApiV3_1Document(doc: unknown): doc is OpenAPIV3_1.Document { return !!doc && isPlainObject(doc) && 'openapi' in (doc as object) && (doc as { openapi: string }).openapi.startsWith('3.1') } /** * Returns `true` when `obj` is a JSON Schema object recognized by the `oas` library. */ export function isJSONSchema(obj?: unknown): obj is SchemaObject { return !!obj && isSchema(obj) } /** * Returns `true` when `obj` is a parameter object (has an `in` field distinguishing it from a schema). */ export function isParameterObject(obj: ParameterObject | SchemaObject): obj is ParameterObject { return !!obj && 'in' in obj } /** * Determines if a schema is nullable, considering: * - OpenAPI 3.0 `nullable` / `x-nullable` * - OpenAPI 3.1 JSON Schema `type: ['null', ...]` or `type: 'null'` */ export function isNullable(schema?: SchemaObject & { 'x-nullable'?: boolean }): boolean { const explicitNullable = schema?.nullable ?? schema?.['x-nullable'] if (explicitNullable === true) { return true } const schemaType = schema?.type if (schemaType === 'null') { return true } if (Array.isArray(schemaType)) { return schemaType.includes('null') } return false } /** * Returns `true` when `obj` is an OpenAPI `$ref` pointer object. */ export function isReference(obj?: unknown): obj is OpenAPIV3.ReferenceObject | OpenAPIV3_1.ReferenceObject { return !!obj && isRef(obj as object) } /** * Returns `true` when `obj` is a schema that carries a structured `discriminator` object * (as opposed to a plain string discriminator used in some older specs). */ export function isDiscriminator(obj?: unknown): obj is SchemaObject & { discriminator: OpenAPIV3.DiscriminatorObject } { const record = obj as Record<string, unknown> return !!obj && !!record['discriminator'] && typeof record['discriminator'] !== 'string' } /** * Determines whether a schema is required. * * Returns true if the schema has a non-empty {@link SchemaObject.required} array or a truthy {@link SchemaObject.required} property. */ export function isRequired(schema?: SchemaObject): boolean { if (!schema) { return false } return Array.isArray(schema.required) ? !!schema.required?.length : !!schema.required } // Helper to determine if a schema (and its composed children) has no required fields // This prefers structural optionality over top-level optional flag type JSONSchemaLike = | { required?: readonly string[] allOf?: readonly unknown[] anyOf?: readonly unknown[] oneOf?: readonly unknown[] } | undefined //TODO make isAllOptional more like isOptional with better typings export function isAllOptional(schema: unknown): boolean { // If completely absent, consider it optional in context of defaults if (!schema) return true // If the entire schema itself is optional, it's safe to default if (isOptional(schema)) return true const s = schema as JSONSchemaLike const hasRequired = Array.isArray(s?.required) && s?.required.length > 0 if (hasRequired) return false const groups = [s?.allOf, s?.anyOf, s?.oneOf].filter((g): g is readonly unknown[] => Array.isArray(g)) if (groups.length === 0) return true // Be conservative: only when all composed parts are all-optional we treat it as all-optional return groups.every((arr) => arr.every((child) => isAllOptional(child))) } export function isOptional(schema?: SchemaObject): boolean { return !isRequired(schema) } /** * Determines the appropriate default value for a schema parameter. * - For array types: returns '[]' * - For union types (anyOf/oneOf): * - If at least one variant has all-optional fields: returns '{}' * - Otherwise: returns undefined (no default) * - For object types with optional fields: returns '{}' * - For primitive types (string, number, boolean): returns undefined (no default) * - For required types: returns undefined (no default) */ export function getDefaultValue(schema?: SchemaObject): string | undefined { if (!schema || !isOptional(schema)) { return undefined } // For array types, use empty array as default if (schema.type === 'array') { return '[]' } // For union types (anyOf/oneOf), check if any variant could accept an empty object if (schema.anyOf || schema.oneOf) { const variants = schema.anyOf || schema.oneOf if (!Array.isArray(variants)) { return undefined } // Only provide default if at least one variant has all-optional fields const hasEmptyObjectVariant = variants.some((variant) => isAllOptional(variant)) if (!hasEmptyObjectVariant) { return undefined } // At least one variant accepts empty object return '{}' } // For object types (or schemas with properties), use empty object as default // This is safe because we already checked isOptional above if (schema.type === 'object' || schema.properties) { return '{}' } // For other types (primitives like string, number, boolean), no default return undefined } export async function parse( pathOrApi: string | Document, { oasClass = Oas, canBundle = true, enablePaths = true }: { oasClass?: typeof Oas; canBundle?: boolean; enablePaths?: boolean } = {}, ): Promise<Oas> { if (typeof pathOrApi === 'string' && canBundle) { // resolve external refs const config = await loadConfig() const bundleResults = await bundle({ ref: pathOrApi, config, base: pathOrApi }) return parse(bundleResults.bundle.parsed as string, { oasClass, canBundle, enablePaths }) } const oasNormalize = new OASNormalize(pathOrApi, { enablePaths, colorizeErrors: true, }) const document = (await oasNormalize.load()) as Document if (isOpenApiV2Document(document)) { const { openapi } = await swagger2openapi.convertObj(document, { anchors: true, }) return new oasClass(openapi as Document) } return new oasClass(document) } export async function merge(pathOrApi: Array<string | Document>, { oasClass = Oas }: { oasClass?: typeof Oas } = {}): Promise<Oas> { const instances = await Promise.all(pathOrApi.map((p) => parse(p, { oasClass, enablePaths: false, canBundle: false }))) if (instances.length === 0) { throw new Error('No OAS instances provided for merging.') } const merged = instances.reduce( (acc, current) => { return mergeDeep(acc, current.document as Document) }, { openapi: '3.0.0', info: { title: 'Merged API', version: '1.0.0', }, paths: {}, components: { schemas: {}, }, } as any, ) return parse(merged, { oasClass }) } export function parseFromConfig(config: Config, oasClass: typeof Oas = Oas): Promise<Oas> { if ('data' in config.input) { if (typeof config.input.data === 'object') { const api: Document = structuredClone(config.input.data) as Document return parse(api, { oasClass }) } // data is a string - try YAML first, then fall back to passing to parse() try { const api: string = yaml.parse(config.input.data as string) return parse(api, { oasClass }) } catch (_e) { // YAML parse failed, let parse() handle it (supports JSON strings and more) return parse(config.input.data as string, { oasClass }) } } if (Array.isArray(config.input)) { return merge( config.input.map((input) => path.resolve(config.root, input.path)), { oasClass }, ) } if (new URLPath(config.input.path).isURL) { return parse(config.input.path, { oasClass }) } return parse(path.resolve(config.root, config.input.path), { oasClass }) } /** * Flatten allOf schemas by merging keyword-only fragments. * Only flattens schemas where allOf items don't contain structural keys or $refs. */ export function flattenSchema(schema: SchemaObject | null): SchemaObject | null { if (!schema?.allOf || schema.allOf.length === 0) { return schema || null } // Never touch ref-based or structural composition if (schema.allOf.some((item) => isRef(item))) { return schema } const isPlainFragment = (item: SchemaObject) => !Object.keys(item).some((key) => STRUCTURAL_KEYS.has(key)) // Only flatten keyword-only fragments if (!schema.allOf.every((item) => isPlainFragment(item as SchemaObject))) { return schema } const merged: SchemaObject = { ...schema } delete merged.allOf for (const fragment of schema.allOf as SchemaObject[]) { for (const [key, value] of Object.entries(fragment)) { if (merged[key as keyof typeof merged] === undefined) { merged[key as keyof typeof merged] = value } } } return merged } /** * Validate an OpenAPI document using oas-normalize. */ export async function validate(document: Document) { const oasNormalize = new OASNormalize(document, { enablePaths: true, colorizeErrors: true, }) return oasNormalize.validate({ parser: { validate: { errors: { colorize: true, }, }, }, }) } type SchemaSourceMode = 'schemas' | 'responses' | 'requestBodies' export type SchemaWithMetadata = { schema: SchemaObject source: SchemaSourceMode originalName: string } type GetSchemasResult = { schemas: Record<string, SchemaObject> nameMapping: Map<string, string> } /** * Collect all schema $ref dependencies recursively. */ export function collectRefs(schema: unknown, refs = new Set<string>()): Set<string> { if (Array.isArray(schema)) { for (const item of schema) { collectRefs(item, refs) } return refs } if (schema && typeof schema === 'object') { for (const [key, value] of Object.entries(schema)) { if (key === '$ref' && typeof value === 'string') { const match = value.match(/^#\/components\/schemas\/(.+)$/) if (match) { refs.add(match[1]!) } } else { collectRefs(value, refs) } } } return refs } /** * Sort schemas topologically so referenced schemas appear first. */ export function sortSchemas(schemas: Record<string, SchemaObject>): Record<string, SchemaObject> { const deps = new Map<string, string[]>() for (const [name, schema] of Object.entries(schemas)) { deps.set(name, Array.from(collectRefs(schema))) } const sorted: string[] = [] const visited = new Set<string>() function visit(name: string, stack = new Set<string>()) { if (visited.has(name)) { return } if (stack.has(name)) { return } // circular refs, ignore stack.add(name) const children = deps.get(name) || [] for (const child of children) { if (deps.has(child)) { visit(child, stack) } } stack.delete(name) visited.add(name) sorted.push(name) } for (const name of Object.keys(schemas)) { visit(name) } const sortedSchemas: Record<string, SchemaObject> = {} for (const name of sorted) { sortedSchemas[name] = schemas[name]! } return sortedSchemas } /** * Extract schema from content object (used by responses and requestBodies). * Returns null if the schema is just a $ref (not a unique type definition). */ export function extractSchemaFromContent(content: Record<string, unknown> | undefined, preferredContentType?: contentType): SchemaObject | null { if (!content) { return null } const firstContentType = Object.keys(content)[0] || 'application/json' const targetContentType = preferredContentType || firstContentType const contentSchema = content[targetContentType] as { schema?: SchemaObject } | undefined const schema = contentSchema?.schema // Skip schemas that are just references - they don't define unique types if (schema && '$ref' in schema) { return null } return schema || null } /** * Get semantic suffix for a schema source. */ export function getSemanticSuffix(source: SchemaSourceMode): string { switch (source) { case 'schemas': return 'Schema' case 'responses': return 'Response' case 'requestBodies': return 'Request' } } /** * Legacy resolution strategy - no collision detection, just use original names. * This preserves backward compatibility when collisionDetection is false. * @deprecated */ export function legacyResolve(schemasWithMeta: SchemaWithMetadata[]): GetSchemasResult { const schemas: Record<string, SchemaObject> = {} const nameMapping = new Map<string, string>() // Simply use original names without collision detection for (const item of schemasWithMeta) { schemas[item.originalName] = item.schema // Map using full $ref path for consistency const refPath = `#/components/${item.source}/${item.originalName}` nameMapping.set(refPath, item.originalName) } return { schemas, nameMapping } } /** * Resolve name collisions by applying suffixes based on collision type. * * Strategy: * - Same-component collisions (e.g., "Variant" + "variant" both in schemas): numeric suffixes (Variant, Variant2) * - Cross-component collisions (e.g., "Pet" in schemas + "Pet" in requestBodies): semantic suffixes (PetSchema, PetRequest) */ export function resolveCollisions(schemasWithMeta: SchemaWithMetadata[]): GetSchemasResult { const schemas: Record<string, SchemaObject> = {} const nameMapping = new Map<string, string>() const normalizedNames = new Map<string, SchemaWithMetadata[]>() // Group schemas by normalized (PascalCase) name for collision detection for (const item of schemasWithMeta) { const normalized = pascalCase(item.originalName) if (!normalizedNames.has(normalized)) { normalizedNames.set(normalized, []) } normalizedNames.get(normalized)!.push(item) } // Process each collision group for (const [, items] of normalizedNames) { if (items.length === 1) { // No collision, use original name const item = items[0]! schemas[item.originalName] = item.schema // Map using full $ref path: #/components/{source}/{originalName} const refPath = `#/components/${item.source}/${item.originalName}` nameMapping.set(refPath, item.originalName) continue } // Multiple schemas normalize to same name - resolve collision const sources = new Set(items.map((item) => item.source)) if (sources.size === 1) { // Same-component collision: add numeric suffixes // Preserve original order from OpenAPI spec for deterministic behavior items.forEach((item, index) => { const suffix = index === 0 ? '' : (index + 1).toString() const uniqueName = item.originalName + suffix schemas[uniqueName] = item.schema // Map using full $ref path: #/components/{source}/{originalName} const refPath = `#/components/${item.source}/${item.originalName}` nameMapping.set(refPath, uniqueName) }) } else { // Cross-component collision: add semantic suffixes // Preserve original order from OpenAPI spec for deterministic behavior items.forEach((item) => { const suffix = getSemanticSuffix(item.source) const uniqueName = item.originalName + suffix schemas[uniqueName] = item.schema // Map using full $ref path: #/components/{source}/{originalName} const refPath = `#/components/${item.source}/${item.originalName}` nameMapping.set(refPath, uniqueName) }) } } return { schemas, nameMapping } }