@alexop/openapi-zod-client
Version:
[](https://openapi-zod-client.vercel.app/)
315 lines (261 loc) • 11.9 kB
text/typescript
import type { ReferenceObject, SchemaObject } from "openapi3-ts";
import { t, ts } from "tanu";
import type { TypeDefinition, TypeDefinitionObject } from "tanu/dist/type";
import { isReferenceObject } from "./isReferenceObject";
import type { DocumentResolver } from "./makeSchemaResolver";
import type { TemplateContext } from "./template-context";
import { wrapWithQuotesIfNeeded } from "./utils";
type TsConversionArgs = {
schema: SchemaObject | ReferenceObject;
ctx?: TsConversionContext | undefined;
meta?: { name?: string; $ref?: string; isInline?: boolean } | undefined;
options?: TemplateContext["options"];
};
export type TsConversionContext = {
nodeByRef: Record<string, ts.Node>;
resolver: DocumentResolver;
rootRef?: string;
visitedsRefs?: Record<string, boolean>;
};
type MaybeWrapReadOnlyType =
| ts.TypeNode
| {
[k: string]:
| number
| bigint
| boolean
| TypeDefinitionObject
| ts.TypeNode
| ts.TypeAliasDeclaration
| ts.InterfaceDeclaration
| ts.EnumDeclaration;
};
const wrapReadOnly =
(options: TemplateContext["options"]) =>
(theType: MaybeWrapReadOnlyType): MaybeWrapReadOnlyType => {
if (options?.allReadonly) {
return t.readonly(theType);
}
return theType;
};
export const getTypescriptFromOpenApi = ({
schema,
meta: inheritedMeta,
ctx,
options,
}: // eslint-disable-next-line sonarjs/cognitive-complexity
TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
const meta = {} as TsConversionArgs["meta"];
const isInline = !inheritedMeta?.name;
const doWrapReadOnly = wrapReadOnly(options);
if (ctx?.visitedsRefs && inheritedMeta?.$ref) {
ctx.rootRef = inheritedMeta.$ref;
ctx.visitedsRefs[inheritedMeta.$ref] = true;
}
if (!schema) {
throw new Error("Schema is required");
}
let canBeWrapped = !isInline;
const getTs = (): ts.Node | TypeDefinitionObject | string => {
if (isReferenceObject(schema)) {
if (!ctx?.visitedsRefs || !ctx?.resolver) throw new Error("Context is required for OpenAPI $ref");
let result = ctx.nodeByRef[schema.$ref];
let schemaName = ctx.resolver.resolveRef(schema.$ref)?.normalized;
if (ctx.visitedsRefs[schema.$ref]) {
return t.reference(schemaName);
}
if (!result) {
const actualSchema = ctx.resolver.getSchemaByRef(schema.$ref);
if (!actualSchema) {
throw new Error(`Schema ${schema.$ref} not found`);
}
ctx.visitedsRefs[schema.$ref] = true;
result = getTypescriptFromOpenApi({ schema: actualSchema, meta, ctx, options }) as ts.Node;
}
if (!schemaName) {
schemaName = ctx.resolver.resolveRef(schema.$ref)?.normalized;
}
return t.reference(schemaName);
}
if (Array.isArray(schema.type)) {
if (schema.type.length === 1) {
return getTypescriptFromOpenApi({ schema: { ...schema, type: schema.type[0]! }, ctx, meta, options });
}
const types = schema.type.map(
(prop) =>
getTypescriptFromOpenApi({
schema: { ...schema, type: prop },
ctx,
meta,
options,
}) as TypeDefinition
);
return schema.nullable ? t.union([...types, t.reference("null")]) : t.union(types);
}
if (schema.type === "null") {
return t.reference("null");
}
if (schema.oneOf) {
if (schema.oneOf.length === 1) {
return getTypescriptFromOpenApi({ schema: schema.oneOf[0]!, ctx, meta, options });
}
const types = schema.oneOf.map(
(prop) => getTypescriptFromOpenApi({ schema: prop, ctx, meta, options }) as TypeDefinition
);
return schema.nullable ? t.union([...types, t.reference("null")]) : t.union(types);
}
// anyOf = oneOf but with 1 or more = `T extends oneOf ? T | T[] : never`
if (schema.anyOf) {
if (schema.anyOf.length === 1) {
return getTypescriptFromOpenApi({ schema: schema.anyOf[0]!, ctx, meta, options });
}
const oneOf = t.union(
schema.anyOf.map(
(prop) => getTypescriptFromOpenApi({ schema: prop, ctx, meta, options }) as TypeDefinition
)
);
return schema.nullable
? t.union([oneOf, doWrapReadOnly(t.array(oneOf)), t.reference("null")])
: t.union([oneOf, doWrapReadOnly(t.array(oneOf))]);
}
if (schema.allOf) {
if (schema.allOf.length === 1) {
return getTypescriptFromOpenApi({ schema: schema.allOf[0]!, ctx, meta, options });
}
const types = schema.allOf.map(
(prop) => getTypescriptFromOpenApi({ schema: prop, ctx, meta, options }) as TypeDefinition
);
return schema.nullable ? t.union([t.intersection(types), t.reference("null")]) : t.intersection(types);
}
const schemaType = schema.type ? (schema.type.toLowerCase() as NonNullable<typeof schema.type>) : undefined;
if (schemaType && isPrimitiveType(schemaType)) {
if (schema.enum) {
if (schemaType !== "string" && schema.enum.some((e) => typeof e === "string")) {
return schema.nullable ? t.union([t.never(), t.reference("null")]) : t.never();
}
return schema.nullable ? t.union([...schema.enum, t.reference("null")]) : t.union(schema.enum);
}
if (schemaType === "string")
return schema.nullable ? t.union([t.string(), t.reference("null")]) : t.string();
if (schemaType === "boolean")
return schema.nullable ? t.union([t.boolean(), t.reference("null")]) : t.boolean();
if (schemaType === "number" || schemaType === "integer")
return schema.nullable ? t.union([t.number(), t.reference("null")]) : t.number();
}
if (schemaType === "array") {
if (schema.items) {
let arrayOfType = getTypescriptFromOpenApi({
schema: schema.items,
ctx,
meta,
options,
}) as TypeDefinition;
if (typeof arrayOfType === "string") {
if (!ctx) throw new Error("Context is required for circular $ref (recursive schemas)");
arrayOfType = t.reference(arrayOfType);
}
return schema.nullable
? t.union([doWrapReadOnly(t.array(arrayOfType)), t.reference("null")])
: doWrapReadOnly(t.array(arrayOfType));
}
return schema.nullable
? t.union([doWrapReadOnly(t.array(t.any())), t.reference("null")])
: doWrapReadOnly(t.array(t.any()));
}
if (schemaType === "object" || schema.properties || schema.additionalProperties) {
if (!schema.properties) {
return {};
}
canBeWrapped = false;
const isPartial = !schema.required?.length;
let additionalProperties;
if (schema.additionalProperties) {
let additionalPropertiesType;
if (
(typeof schema.additionalProperties === "boolean" && schema.additionalProperties) ||
(typeof schema.additionalProperties === "object" &&
Object.keys(schema.additionalProperties).length === 0)
) {
additionalPropertiesType = t.any();
} else if (typeof schema.additionalProperties === "object") {
additionalPropertiesType = getTypescriptFromOpenApi({
schema: schema.additionalProperties,
ctx,
meta,
options,
});
}
additionalProperties = ts.factory.createTypeLiteralNode([
ts.factory.createIndexSignature(
undefined,
[
ts.factory.createParameterDeclaration(
undefined,
undefined,
ts.factory.createIdentifier("key"),
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)
),
],
additionalPropertiesType as ts.TypeNode
),
]);
}
const props = Object.fromEntries(
Object.entries(schema.properties).map(([prop, propSchema]) => {
let propType = getTypescriptFromOpenApi({
schema: propSchema,
ctx,
meta,
options,
}) as TypeDefinition;
if (typeof propType === "string") {
if (!ctx) throw new Error("Context is required for circular $ref (recursive schemas)");
// TODO Partial ?
propType = t.reference(propType);
}
const isRequired = Boolean(isPartial ? true : schema.required?.includes(prop));
return [`${wrapWithQuotesIfNeeded(prop)}`, isRequired ? propType : t.optional(propType)];
})
);
const objectType = additionalProperties ? t.intersection([props, additionalProperties]) : props;
if (isInline) {
return isPartial ? t.reference("Partial", [doWrapReadOnly(objectType)]) : doWrapReadOnly(objectType);
}
if (!inheritedMeta?.name) {
throw new Error("Name is required to convert an object schema to a type reference");
}
const base = t.type(inheritedMeta.name, doWrapReadOnly(objectType));
if (!isPartial) return base;
return t.type(inheritedMeta.name, t.reference("Partial", [doWrapReadOnly(objectType)]));
}
if (!schemaType) return t.unknown();
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unsupported schema type: ${schemaType}`);
};
const tsResult = getTs();
return canBeWrapped
? wrapTypeIfInline({ isInline, name: inheritedMeta?.name, typeDef: tsResult as TypeDefinition })
: tsResult;
};
type SingleType = Exclude<SchemaObject["type"], any[] | undefined>;
const isPrimitiveType = (type: SingleType): type is PrimitiveType => primitiveTypeList.includes(type as any);
const primitiveTypeList = ["string", "number", "integer", "boolean", "null"] as const;
type PrimitiveType = typeof primitiveTypeList[number];
const wrapTypeIfInline = ({
isInline,
name,
typeDef,
}: {
isInline: boolean;
name: string | undefined;
typeDef: t.TypeDefinition;
}) => {
if (!isInline) {
if (!name) {
throw new Error("Name is required to convert a schema to a type reference");
}
return t.type(name, typeDef);
}
return typeDef as ts.Node;
};