UNPKG

typed-openapi

Version:
332 lines (284 loc) 11.4 kB
import { capitalize, groupBy } from "pastable/server"; import { Box } from "./box"; import { prettify } from "./format"; import { mapOpenApiEndpoints } from "./map-openapi-endpoints"; import { AnyBox, AnyBoxDef } from "./types"; import * as Codegen from "@sinclair/typebox-codegen"; import { match } from "ts-pattern"; import { type } from "arktype"; import { wrapWithQuotesIfNeeded } from "./string-utils"; type GeneratorOptions = ReturnType<typeof mapOpenApiEndpoints> & { runtime?: "none" | keyof typeof runtimeValidationGenerator; }; type GeneratorContext = Required<GeneratorOptions>; export const allowedRuntimes = type("'none' | 'arktype' | 'io-ts' | 'typebox' | 'valibot' | 'yup' | 'zod'"); export type OutputRuntime = typeof allowedRuntimes.infer; // TODO validate response schemas in sample fetch ApiClient // also, check that we can easily retrieve the response schema from the Fetcher const runtimeValidationGenerator = { arktype: Codegen.ModelToArkType.Generate, "io-ts": Codegen.ModelToIoTs.Generate, typebox: Codegen.ModelToTypeBox.Generate, valibot: Codegen.ModelToValibot.Generate, yup: Codegen.ModelToYup.Generate, zod: Codegen.ModelToZod.Generate, }; const inferByRuntime = { none: (input: string) => input, arktype: (input: string) => `${input}["infer"]`, "io-ts": (input: string) => `t.TypeOf<${input}>`, typebox: (input: string) => `Static<${input}>`, valibot: (input: string) => `v.InferOutput<${input}>`, yup: (input: string) => `y.InferType<${input}>`, zod: (input: string) => `z.infer<${input}>`, }; const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; const methodsRegex = new RegExp(`(?:${methods.join("|")})_`); const endpointExport = new RegExp(`export (?:type|const) (?:${methodsRegex.source})`); const replacerByRuntime = { yup: (line: string) => line .replace(/y\.InferType<\s*?typeof (.*?)\s*?>/g, "typeof $1") .replace(new RegExp(`(${endpointExport.source})` + new RegExp(/([\s\S]*? )(y\.object)(\()/).source, "g"), "$1$2("), zod: (line: string) => line .replace(/z\.infer<\s*?typeof (.*?)\s*?>/g, "typeof $1") .replace(new RegExp(`(${endpointExport.source})` + new RegExp(/([\s\S]*? )(z\.object)(\()/).source, "g"), "$1$2("), }; export const generateFile = (options: GeneratorOptions) => { const ctx = { ...options, runtime: options.runtime ?? "none" } as GeneratorContext; const schemaList = generateSchemaList(ctx); const endpointSchemaList = generateEndpointSchemaList(ctx); const apiClient = generateApiClient(ctx); const transform = ctx.runtime === "none" ? (file: string) => file : (file: string) => { const model = Codegen.TypeScriptToModel.Generate(file); const transformer = runtimeValidationGenerator[ctx.runtime as Exclude<typeof ctx.runtime, "none">]; // tmp fix for typebox, there's currently a "// todo" only with Codegen.ModelToTypeBox.Generate // https://github.com/sinclairzx81/typebox-codegen/blob/44d44d55932371b69f349331b1c8a60f5d760d9e/src/model/model-to-typebox.ts#L31 const generated = ctx.runtime === "typebox" ? Codegen.TypeScriptToTypeBox.Generate(file) : transformer(model); let converted = ""; const match = generated.match(/(const __ENDPOINTS_START__ =)([\s\S]*?)(export type __ENDPOINTS_END__)/); const content = match?.[2]; if (content && ctx.runtime in replacerByRuntime) { const before = generated.slice(0, generated.indexOf("export type __ENDPOINTS_START")); converted = before + replacerByRuntime[ctx.runtime as keyof typeof replacerByRuntime]( content.slice(content.indexOf("export")), ); } else { converted = generated; } return converted; }; const file = ` ${transform(schemaList + endpointSchemaList)} ${apiClient} `; return prettify(file); }; const generateSchemaList = ({ refs, runtime }: GeneratorContext) => { let file = ` ${runtime === "none" ? "export namespace Schemas {" : ""} // <Schemas> `; refs.getOrderedSchemas().forEach(([schema, infos]) => { if (!infos?.name) return; if (infos.kind !== "schemas") return; file += `export type ${infos.normalized} = ${schema.value}\n`; }); return ( file + ` // </Schemas> ${runtime === "none" ? "}" : ""} ` ); }; const parameterObjectToString = (parameters: Box<AnyBoxDef> | Record<string, AnyBox>) => { if (parameters instanceof Box) return parameters.value; let str = "{"; for (const [key, box] of Object.entries(parameters)) { str += `${wrapWithQuotesIfNeeded(key)}: ${box.value},\n`; } return str + "}"; }; const generateEndpointSchemaList = (ctx: GeneratorContext) => { let file = ` ${ctx.runtime === "none" ? "export namespace Endpoints {" : ""} // <Endpoints> ${ctx.runtime === "none" ? "" : "type __ENDPOINTS_START__ = {}"} `; ctx.endpointList.map((endpoint) => { const parameters = endpoint.parameters ?? {}; file += `export type ${endpoint.meta.alias} = { method: "${endpoint.method.toUpperCase()}", path: "${endpoint.path}", requestFormat: "${endpoint.requestFormat}", ${ endpoint.meta.hasParameters ? `parameters: { ${parameters.query ? `query: ${parameterObjectToString(parameters.query)},` : ""} ${parameters.path ? `path: ${parameterObjectToString(parameters.path)},` : ""} ${parameters.header ? `header: ${parameterObjectToString(parameters.header)},` : ""} ${ parameters.body ? `body: ${parameterObjectToString( ctx.runtime === "none" ? parameters.body.recompute((box) => { if (Box.isReference(box) && !box.params.generics) { box.value = `Schemas.${box.value}`; } return box; }) : parameters.body, )},` : "" } }` : "parameters: never," } response: ${ ctx.runtime === "none" ? endpoint.response.recompute((box) => { if (Box.isReference(box) && !box.params.generics) { box.value = `Schemas.${box.value}`; } return box; }).value : endpoint.response.value }, }\n`; }); return ( file + ` // </Endpoints> ${ctx.runtime === "none" ? "}" : ""} ${ctx.runtime === "none" ? "" : "type __ENDPOINTS_END__ = {}"} ` ); }; const generateEndpointByMethod = (ctx: GeneratorContext) => { const { endpointList } = ctx; const byMethods = groupBy(endpointList, "method"); const endpointByMethod = ` // <EndpointByMethod> export ${ctx.runtime === "none" ? "type" : "const"} EndpointByMethod = { ${Object.entries(byMethods) .map(([method, list]) => { return `${method}: { ${list.map( (endpoint) => `"${endpoint.path}": ${ctx.runtime === "none" ? "Endpoints." : ""}${endpoint.meta.alias}`, )} }`; }) .join(",\n")} } ${ctx.runtime === "none" ? "" : "export type EndpointByMethod = typeof EndpointByMethod;"} // </EndpointByMethod> `; const shorthands = ` // <EndpointByMethod.Shorthands> ${Object.keys(byMethods) .map((method) => `export type ${capitalize(method)}Endpoints = EndpointByMethod["${method}"]`) .join("\n")} ${endpointList.length ? `export type AllEndpoints = EndpointByMethod[keyof EndpointByMethod];` : ""} // </EndpointByMethod.Shorthands> `; return endpointByMethod + shorthands; }; const generateApiClient = (ctx: GeneratorContext) => { const { endpointList } = ctx; const byMethods = groupBy(endpointList, "method"); const endpointSchemaList = generateEndpointByMethod(ctx); const apiClientTypes = ` // <ApiClientTypes> export type EndpointParameters = { body?: unknown; query?: Record<string, unknown>; header?: Record<string, unknown>; path?: Record<string, unknown>; }; export type MutationMethod = "post" | "put" | "patch" | "delete"; export type Method = "get" | "head" | "options" | MutationMethod; type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; export type DefaultEndpoint = { parameters?: EndpointParameters | undefined; response: unknown; }; export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = { operationId: string; method: Method; path: string; requestFormat: RequestFormat; parameters?: TConfig["parameters"]; meta: { alias: string; hasParameters: boolean; areParametersRequired: boolean; }; response: TConfig["response"]; }; type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Endpoint["response"]>; type RequiredKeys<T> = { [P in keyof T]-?: undefined extends T[P] ? never : P; }[keyof T]; type MaybeOptionalArg<T> = RequiredKeys<T> extends never ? [config?: T] : [config: T]; // </ApiClientTypes> `; const apiClient = ` // <ApiClient> export class ApiClient { baseUrl: string = ""; constructor(public fetcher: Fetcher) {} setBaseUrl(baseUrl: string) { this.baseUrl = baseUrl; return this; } ${Object.entries(byMethods) .map(([method, endpointByMethod]) => { const capitalizedMethod = capitalize(method); const infer = inferByRuntime[ctx.runtime]; return endpointByMethod.length ? `// <ApiClient.${method}> ${method}<Path extends keyof ${capitalizedMethod}Endpoints, TEndpoint extends ${capitalizedMethod}Endpoints[Path]>( path: Path, ...params: MaybeOptionalArg<${match(ctx.runtime) .with("zod", "yup", () => infer(`TEndpoint["parameters"]`)) .with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["parameters"]`) .otherwise(() => `TEndpoint["parameters"]`)}> ): Promise<${match(ctx.runtime) .with("zod", "yup", () => infer(`TEndpoint["response"]`)) .with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["response"]`) .otherwise(() => `TEndpoint["response"]`)}> { return this.fetcher("${method}", this.baseUrl + path, params[0])${match(ctx.runtime) .with("zod", "yup", () => `as Promise<${infer(`TEndpoint["response"]`)}>`) .with("arktype", "io-ts", "typebox", "valibot", () => `as Promise<${infer(`TEndpoint`) + `["response"]`}>`) .otherwise(() => `as Promise<TEndpoint["response"]>`)}; } // </ApiClient.${method}> ` : ""; }) .join("\n")} } export function createApiClient(fetcher: Fetcher, baseUrl?: string) { return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); } /** Example usage: const api = createApiClient((method, url, params) => fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), ); api.get("/users").then((users) => console.log(users)); api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); */ // </ApiClient `; return endpointSchemaList + apiClientTypes + apiClient; };