@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
text/typescript
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 }
}