|
- #!/usr/bin/env node
- /**
- * Generate a typed fetch client from packages/contracts/openapi.json.
- *
- * The spec is produced by `cargo run -p server --bin export-openapi`, which
- * walks the same utoipa-axum router the server serves. Adding a handler with
- * `#[utoipa::path]` + `.routes(routes!(...))` is enough for it to show up here
- * after `just generate-api`.
- */
- import fs from "node:fs";
- import path from "node:path";
- import { fileURLToPath } from "node:url";
-
- const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
- const specPath = path.join(workspaceRoot, "packages/contracts/openapi.json");
- const outPath = path.join(workspaceRoot, "apps/web/src/api/generated.ts");
-
- const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
-
- function die(message) {
- console.error(message);
- process.exit(1);
- }
-
- function pascal(value) {
- return String(value)
- .replace(/[^A-Za-z0-9]+/g, " ")
- .split(" ")
- .filter(Boolean)
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
- .join("");
- }
-
- function camel(value) {
- const p = pascal(value);
- return p ? p.charAt(0).toLowerCase() + p.slice(1) : "fn";
- }
-
- function uniqueName(base, used) {
- let name = base;
- let i = 2;
- while (used.has(name)) {
- name = `${base}${i}`;
- i += 1;
- }
- used.add(name);
- return name;
- }
-
- function tsType(schema, spec, seen = new Set()) {
- if (!schema) return "unknown";
- if (schema.$ref) {
- const name = schema.$ref.split("/").pop();
- return name ? pascal(name) : "unknown";
- }
- if (schema.allOf?.length) {
- return schema.allOf.map((part) => tsType(part, spec, seen)).join(" & ");
- }
- if (schema.oneOf?.length || schema.anyOf?.length) {
- const parts = (schema.oneOf ?? schema.anyOf).map((part) => tsType(part, spec, seen));
- return [...new Set(parts)].join(" | ");
- }
- if (Array.isArray(schema.enum) && schema.enum.every((item) => typeof item === "string")) {
- return schema.enum.map((item) => JSON.stringify(item)).join(" | ");
- }
- if (Array.isArray(schema.type)) {
- const nullable = schema.type.includes("null");
- const types = schema.type.filter((type) => type !== "null");
- const inner = types.length
- ? types.map((type) => tsType({ ...schema, type }, spec, seen)).join(" | ")
- : "unknown";
- return nullable ? `${inner} | null` : inner;
- }
- switch (schema.type) {
- case "string":
- if (schema.format === "binary") return "Blob";
- return "string";
- case "integer":
- case "number":
- return "number";
- case "boolean":
- return "boolean";
- case "array":
- return `Array<${tsType(schema.items ?? {}, spec, seen)}>`;
- case "object":
- default: {
- const props = schema.properties ?? {};
- const required = new Set(schema.required ?? []);
- const fields = Object.entries(props).map(([key, value]) => {
- const optional = required.has(key) ? "" : "?";
- return ` ${key}${optional}: ${tsType(value, spec, seen)};`;
- });
- if (schema.additionalProperties) {
- const extra =
- schema.additionalProperties === true
- ? "unknown"
- : tsType(schema.additionalProperties, spec, seen);
- fields.push(` [key: string]: ${extra};`);
- }
- if (!fields.length) return "Record<string, unknown>";
- return `{\n${fields.join("\n")}\n}`;
- }
- }
- }
-
- function schemaFromContent(content) {
- if (!content || typeof content !== "object") return null;
- return (
- content["application/json"]?.schema ??
- content["application/problem+json"]?.schema ??
- Object.values(content)[0]?.schema ??
- null
- );
- }
-
- function successResponse(operation) {
- const responses = operation.responses ?? {};
- const preferred = ["200", "201", "202", "204"];
- for (const code of preferred) {
- if (responses[code]) return { code, response: responses[code] };
- }
- const fallback = Object.entries(responses).find(([code]) => code.startsWith("2"));
- return fallback ? { code: fallback[0], response: fallback[1] } : { code: "200", response: {} };
- }
-
- function operationName(method, pathName, operation, used) {
- if (operation.operationId) {
- return uniqueName(camel(operation.operationId), used);
- }
- const slug = pathName.replace(/[{}]/g, "").replace(/^\//, "").replaceAll("/", "_");
- return uniqueName(camel(`${method}_${slug}`), used);
- }
-
- function pathToTemplate(pathName, paramNames) {
- let template = JSON.stringify(pathName);
- for (const name of paramNames) {
- template = template.replace(`{${name}}`, `" + encodeURIComponent(String(args.path.${name})) + "`);
- }
- return template.replace(/ \+ ""/g, "");
- }
-
- if (!fs.existsSync(specPath)) {
- die(`missing ${specPath} — run: cargo run -p server --bin export-openapi`);
- }
-
- const spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
- const schemas = spec.components?.schemas ?? {};
- const usedNames = new Set();
- const typeDecls = [];
- const fnDecls = [];
-
- for (const [name, schema] of Object.entries(schemas)) {
- typeDecls.push(`export type ${pascal(name)} = ${tsType(schema, spec)};`);
- }
-
- const paths = spec.paths ?? {};
- for (const [pathName, item] of Object.entries(paths)) {
- for (const method of HTTP_METHODS) {
- const operation = item?.[method];
- if (!operation) continue;
-
- const name = operationName(method, pathName, operation, usedNames);
- const allParams = [...(item.parameters ?? []), ...(operation.parameters ?? [])];
- const pathParams = allParams.filter((param) => param.in === "path");
- const queryParams = allParams.filter((param) => param.in === "query");
- const bodySchema = schemaFromContent(operation.requestBody?.content);
- const { code: status, response } = successResponse(operation);
- const responseSchema = schemaFromContent(response?.content);
-
- const args = [];
- if (pathParams.length) {
- const fields = pathParams
- .map((param) => {
- const schema = param.schema ?? { type: "string" };
- return ` ${param.name}: ${tsType(schema, spec)};`;
- })
- .join("\n");
- args.push(`path: {\n${fields}\n }`);
- }
- if (queryParams.length) {
- const fields = queryParams
- .map((param) => {
- const schema = param.schema ?? { type: "string" };
- const optional = param.required ? "" : "?";
- return ` ${param.name}${optional}: ${tsType(schema, spec)};`;
- })
- .join("\n");
- args.push(`query?: {\n${fields}\n }`);
- }
- if (bodySchema) {
- args.push(`body: ${tsType(bodySchema, spec)}`);
- }
-
- const argList = args.length ? `args: {\n ${args.join(";\n ")}\n}` : "";
- const returnType = responseSchema ? tsType(responseSchema, spec) : "void";
- const pathExpr = pathToTemplate(
- pathName,
- pathParams.map((param) => param.name),
- );
- const queryLine = queryParams.length ? " query: args.query," : "";
- const bodyLine = bodySchema ? " body: args.body," : "";
-
- const requestFields = [
- ` method: "${method.toUpperCase()}",`,
- ` path: ${pathExpr},`,
- queryLine,
- bodyLine,
- ` expectedStatus: ${Number(status) || 200},`,
- ].filter(Boolean);
-
- fnDecls.push(`export function ${name}(${argList}): Promise<${returnType}> {
- return request<${returnType}>({
- ${requestFields.join("\n")}
- });
- }`);
- }
- }
-
- const banner = `/* eslint-disable */
- /* generated by scripts/generate-api.mjs — do not edit */
- import { request } from "./client";
-
- `;
-
- const output = `${banner}${typeDecls.join("\n\n")}\n\n${fnDecls.join("\n\n")}\n`;
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
- fs.writeFileSync(outPath, output);
- console.log(`wrote ${outPath}`);
|