openapi-typescript
Version:
Convert OpenAPI 3.0 & 3.1 schemas to TypeScript
561 lines (533 loc) • 17.7 kB
text/typescript
import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
import ts from "typescript";
import {
BOOLEAN,
NEVER,
NULL,
NUMBER,
QUESTION_TOKEN,
STRING,
UNDEFINED,
UNKNOWN,
addJSDocComment,
oapiRef,
tsEnum,
tsIntersection,
tsIsPrimitive,
tsLiteral,
tsModifiers,
tsNullable,
tsOmit,
tsPropertyIndex,
tsRecord,
tsUnion,
tsWithRequired,
} from "../lib/ts.js";
import {
createDiscriminatorProperty,
createRef,
getEntries,
} from "../lib/utils.js";
import {
ReferenceObject,
SchemaObject,
TransformNodeOptions,
} from "../types.js";
/**
* Transform SchemaObject nodes (4.8.24)
* @see https://spec.openapis.org/oas/v3.1.0#schema-object
*/
export default function transformSchemaObject(
schemaObject: SchemaObject | ReferenceObject,
options: TransformNodeOptions,
): ts.TypeNode {
const type = transformSchemaObjectWithComposition(schemaObject, options);
if (typeof options.ctx.postTransform === "function") {
const postTransformResult = options.ctx.postTransform(type, options);
if (postTransformResult) {
return postTransformResult;
}
}
return type;
}
/**
* Transform SchemaObjects
*/
export function transformSchemaObjectWithComposition(
schemaObject: SchemaObject | ReferenceObject,
options: TransformNodeOptions,
): ts.TypeNode {
/**
* Unexpected types & edge cases
*/
// missing/falsy type returns `never`
if (!schemaObject) {
return NEVER;
}
// `true` returns `unknown` (this exists, but is untyped)
if ((schemaObject as unknown) === true) {
return UNKNOWN;
}
// for any other unexpected type, throw error
if (Array.isArray(schemaObject) || typeof schemaObject !== "object") {
throw new Error(
`Expected SchemaObject, received ${
Array.isArray(schemaObject) ? "Array" : typeof schemaObject
}`,
);
}
/**
* ReferenceObject
*/
if ("$ref" in schemaObject) {
return oapiRef(schemaObject.$ref);
}
/**
* const (valid for any type)
*/
if (schemaObject.const !== null && schemaObject.const !== undefined) {
return tsLiteral(schemaObject.const);
}
/**
* enum (non-objects)
* note: enum is valid for any type, but for objects, handle in oneOf below
*/
if (
Array.isArray(schemaObject.enum) &&
(!("type" in schemaObject) || schemaObject.type !== "object") &&
!("properties" in schemaObject) &&
!("additionalProperties" in schemaObject)
) {
// hoist enum to top level if string/number enum and option is enabled
if (
options.ctx.enum &&
schemaObject.enum.every(
(v) => typeof v === "string" || typeof v === "number",
)
) {
let enumName = parseRef(options.path ?? "").pointer.join("/");
// allow #/components/schemas to have simpler names
enumName = enumName.replace("components/schemas", "");
const metadata = schemaObject.enum.map((_, i) => ({
name: schemaObject["x-enum-varnames"]?.[i],
description: schemaObject["x-enum-descriptions"]?.[i],
}));
const enumType = tsEnum(
enumName,
schemaObject.enum as (string | number)[],
metadata,
{ export: true, readonly: options.ctx.immutable },
);
options.ctx.injectFooter.push(enumType);
return ts.factory.createTypeReferenceNode(enumType.name);
}
return tsUnion(schemaObject.enum.map(tsLiteral));
}
/**
* Object + composition (anyOf/allOf/oneOf) types
*/
/** Collect oneOf/allOf/anyOf with Omit<> for discriminators */
function collectCompositions(
items: (SchemaObject | ReferenceObject)[],
required?: string[],
): ts.TypeNode[] {
const output: ts.TypeNode[] = [];
for (const item of items) {
let itemType: ts.TypeNode;
// if this is a $ref, use WithRequired<X, Y> if parent specifies required properties
// (but only for valid keys)
if ("$ref" in item) {
itemType = transformSchemaObject(item, options);
const resolved = options.ctx.resolve<SchemaObject>(item.$ref);
if (
resolved &&
typeof resolved === "object" &&
"properties" in resolved
) {
// don’t try and make keys required if the $ref doesn’t have them
const validRequired = (required ?? []).filter(
(key) => !!resolved.properties![key],
);
if (validRequired.length) {
itemType = tsWithRequired(
itemType,
validRequired,
options.ctx.injectFooter,
);
}
}
}
// otherwise, if this is a schema object, combine parent `required[]` with its own, if any
else {
const itemRequired = [...(required ?? [])];
if (typeof item === "object" && Array.isArray(item.required)) {
itemRequired.push(...item.required);
}
itemType = transformSchemaObject(
{ ...item, required: itemRequired },
options,
);
}
const discriminator =
("$ref" in item && options.ctx.discriminators[item.$ref]) ||
(item as any).discriminator; // eslint-disable-line @typescript-eslint/no-explicit-any
if (discriminator) {
output.push(tsOmit(itemType, [discriminator.propertyName]));
} else {
output.push(itemType);
}
}
return output;
}
// compile final type
let finalType: ts.TypeNode | undefined = undefined;
// core + allOf: intersect
const coreObjectType = transformSchemaObjectCore(schemaObject, options);
const allOfType = collectCompositions(
schemaObject.allOf ?? [],
schemaObject.required,
);
if (coreObjectType || allOfType.length) {
const allOf: ts.TypeNode | undefined = allOfType.length
? tsIntersection(allOfType)
: undefined;
finalType = tsIntersection([
...(coreObjectType ? [coreObjectType] : []),
...(allOf ? [allOf] : []),
]);
}
// anyOf: union
// (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf)
const anyOfType = collectCompositions(
schemaObject.anyOf ?? [],
schemaObject.required,
);
if (anyOfType.length) {
finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]);
}
// oneOf: union (within intersection with other types, if any)
const oneOfType = collectCompositions(
schemaObject.oneOf ||
("type" in schemaObject &&
schemaObject.type === "object" &&
(schemaObject.enum as (SchemaObject | ReferenceObject)[])) ||
[],
schemaObject.required,
);
if (oneOfType.length) {
// note: oneOf is the only type that may include primitives
if (oneOfType.every(tsIsPrimitive)) {
finalType = tsUnion([...(finalType ? [finalType] : []), ...oneOfType]);
} else {
finalType = tsIntersection([
...(finalType ? [finalType] : []),
tsUnion(oneOfType),
]);
}
}
// if final type could be generated, return intersection of all members
if (finalType) {
// deprecated nullable
if (schemaObject.nullable) {
return tsNullable([finalType]);
}
return finalType;
}
// otherwise fall back to unknown type (or related variants)
else {
// fallback: unknown
if (!("type" in schemaObject)) {
return UNKNOWN;
}
// if no type could be generated, fall back to “empty object” type
return tsRecord(STRING, options.ctx.emptyObjectsUnknown ? UNKNOWN : NEVER);
}
}
/**
* Handle SchemaObject minus composition (anyOf/allOf/oneOf)
*/
function transformSchemaObjectCore(
schemaObject: SchemaObject,
options: TransformNodeOptions,
): ts.TypeNode | undefined {
if ("type" in schemaObject && schemaObject.type) {
// primitives
// type: null
if (schemaObject.type === "null") {
return NULL;
}
// type: string
if (schemaObject.type === "string") {
return STRING;
}
// type: number / type: integer
if (schemaObject.type === "number" || schemaObject.type === "integer") {
return NUMBER;
}
// type: boolean
if (schemaObject.type === "boolean") {
return BOOLEAN;
}
// type: array (with support for tuples)
if (schemaObject.type === "array") {
// default to `unknown[]`
let itemType: ts.TypeNode = UNKNOWN;
// tuple type
if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) {
const prefixItems =
schemaObject.prefixItems ??
(schemaObject.items as (SchemaObject | ReferenceObject)[]);
itemType = ts.factory.createTupleTypeNode(
prefixItems.map((item) => transformSchemaObject(item, options)),
);
}
// standard array type
else if (schemaObject.items) {
itemType = transformSchemaObject(schemaObject.items, options);
}
const min: number =
typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0
? schemaObject.minItems
: 0;
const max: number | undefined =
typeof schemaObject.maxItems === "number" &&
schemaObject.maxItems >= 0 &&
min <= schemaObject.maxItems
? schemaObject.maxItems
: undefined;
const estimateCodeSize =
typeof max !== "number" ? min : (max * (max + 1) - min * (min - 1)) / 2;
if (
options.ctx.arrayLength &&
(min !== 0 || max !== undefined) &&
estimateCodeSize < 30 // "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
) {
// if maxItems is set, then return a union of all permutations of possible tuple types
if ((schemaObject.maxItems as number) > 0) {
const members: ts.TypeNode[] = [];
// populate 1 short of min …
for (let i = 0; i <= (max ?? 0) - min; i++) {
const elements: ts.TypeNode[] = [];
for (let j = min; j < i + min; j++) {
elements.push(itemType);
}
members.push(ts.factory.createTupleTypeNode(elements));
}
return tsUnion(members);
}
// if maxItems not set, then return a simple tuple type the length of `min`
else {
const elements: ts.TypeNode[] = [];
for (let i = 0; i < min; i++) {
elements.push(itemType);
}
elements.push(
ts.factory.createRestTypeNode(
ts.factory.createArrayTypeNode(itemType),
),
);
return ts.factory.createTupleTypeNode(elements);
}
}
return ts.isTupleTypeNode(itemType)
? itemType
: ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple already
}
// polymorphic, or 3.1 nullable
if (Array.isArray(schemaObject.type) && !Array.isArray(schemaObject)) {
// skip any primitive types that appear in oneOf as well
let uniqueTypes: ts.TypeNode[] = [];
if (Array.isArray(schemaObject.oneOf)) {
for (const t of schemaObject.type) {
if (
(t === "boolean" ||
t === "string" ||
t === "number" ||
t === "integer" ||
t === "null") &&
schemaObject.oneOf.find(
(o) => typeof o === "object" && "type" in o && o.type === t,
)
) {
continue;
}
uniqueTypes.push(
t === "null" || t === null
? NULL
: transformSchemaObject(
{ ...schemaObject, type: t, oneOf: undefined }, // don’t stack oneOf transforms
options,
),
);
}
} else {
uniqueTypes = schemaObject.type.map((t) =>
t === "null" || t === null
? NULL
: transformSchemaObject({ ...schemaObject, type: t }, options),
);
}
return tsUnion(uniqueTypes);
}
}
// type: object
const coreObjectType: ts.TypeElement[] = [];
// discriminatorss: explicit mapping on schema object
for (const k of ["oneOf", "allOf", "anyOf"] as const) {
if (!schemaObject[k]) {
continue;
}
// for all magic inheritance, we will have already gathered it into
// ctx.discriminators. But stop objects from referencing their own
// discriminator meant for children (!schemaObject.discriminator)
const discriminator =
!schemaObject.discriminator && options.ctx.discriminators[options.path!];
if (discriminator) {
coreObjectType.unshift(
createDiscriminatorProperty(discriminator, {
path: options.path!,
readonly: options.ctx.immutable,
}),
);
break;
}
}
if (
("properties" in schemaObject &&
schemaObject.properties &&
Object.keys(schemaObject.properties).length) ||
("additionalProperties" in schemaObject &&
schemaObject.additionalProperties) ||
("$defs" in schemaObject && schemaObject.$defs)
) {
// properties
if (Object.keys(schemaObject.properties ?? {}).length) {
for (const [k, v] of getEntries(
schemaObject.properties ?? {},
options.ctx,
)) {
if (typeof v !== "object" || Array.isArray(v)) {
throw new Error(
`${
options.path
}: invalid property ${k}. Expected Schema Object, got ${
Array.isArray(v) ? "Array" : typeof v
}`,
);
}
// handle excludeDeprecated option
if (options.ctx.excludeDeprecated) {
const resolved =
"$ref" in v ? options.ctx.resolve<SchemaObject>(v.$ref) : v;
if (resolved?.deprecated) {
continue;
}
}
let optional =
schemaObject.required?.includes(k) ||
("default" in v &&
options.ctx.defaultNonNullable &&
!options.path?.includes("parameters")) // parameters can’t be required, even with defaults
? undefined
: QUESTION_TOKEN;
let type =
"$ref" in v
? oapiRef(v.$ref)
: transformSchemaObject(v, {
...options,
path: createRef([options.path ?? "", k]),
});
if (typeof options.ctx.transform === "function") {
const result = options.ctx.transform(v, options);
if (result) {
if ("schema" in result) {
type = result.schema;
optional = result.questionToken ? QUESTION_TOKEN : optional;
} else {
type = result;
}
}
}
const property = ts.factory.createPropertySignature(
/* modifiers */ tsModifiers({
readonly:
options.ctx.immutable || ("readOnly" in v && !!v.readOnly),
}),
/* name */ tsPropertyIndex(k),
/* questionToken */ optional,
/* type */ type,
);
addJSDocComment(v, property);
coreObjectType.push(property);
}
}
// $defs
if (
schemaObject.$defs &&
typeof schemaObject.$defs === "object" &&
Object.keys(schemaObject.$defs).length
) {
const defKeys: ts.TypeElement[] = [];
for (const [k, v] of Object.entries(schemaObject.$defs)) {
const property = ts.factory.createPropertySignature(
/* modifiers */ tsModifiers({
readonly:
options.ctx.immutable || ("readonly" in v && !!v.readOnly),
}),
/* name */ tsPropertyIndex(k),
/* questionToken */ undefined,
/* type */ transformSchemaObject(v, {
...options,
path: createRef([options.path ?? "", "$defs", k]),
}),
);
addJSDocComment(v, property);
defKeys.push(property);
}
coreObjectType.push(
ts.factory.createPropertySignature(
/* modifiers */ undefined,
/* name */ tsPropertyIndex("$defs"),
/* questionToken */ undefined,
/* type */ ts.factory.createTypeLiteralNode(defKeys),
),
);
}
// additionalProperties
if (schemaObject.additionalProperties || options.ctx.additionalProperties) {
const hasExplicitAdditionalProperties =
typeof schemaObject.additionalProperties === "object" &&
Object.keys(schemaObject.additionalProperties).length;
let addlType = hasExplicitAdditionalProperties
? transformSchemaObject(
schemaObject.additionalProperties as SchemaObject,
options,
)
: UNKNOWN;
// allow for `| undefined`, at least until https://github.com/microsoft/TypeScript/issues/4196 is resolved
if (addlType.kind !== ts.SyntaxKind.UnknownKeyword) {
addlType = tsUnion([addlType, UNDEFINED]);
}
coreObjectType.push(
ts.factory.createIndexSignature(
/* modifiers */ tsModifiers({
readonly: options.ctx.immutable,
}),
/* parameters */ [
ts.factory.createParameterDeclaration(
/* modifiers */ undefined,
/* dotDotDotToken */ undefined,
/* name */ ts.factory.createIdentifier("key"),
/* questionToken */ undefined,
/* type */ STRING,
),
],
/* type */ addlType,
),
);
}
}
return coreObjectType.length
? ts.factory.createTypeLiteralNode(coreObjectType)
: undefined;
}