#!/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"; 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, argPrefix) { let template = JSON.stringify(pathName); for (const name of paramNames) { template = template.replace(`{${name}}`, `" + encodeURIComponent(String(${argPrefix}.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 argPrefix = (pathParams.length || queryParams.length || bodySchema) ? "args" : "_args"; const argList = args.length ? `${argPrefix}: {\n ${args.join(";\n ")}\n}` : ""; const returnType = responseSchema ? tsType(responseSchema, spec) : "void"; const pathExpr = pathToTemplate( pathName, pathParams.map((param) => param.name), argPrefix, ); const queryLine = queryParams.length ? ` query: ${argPrefix}.query,` : ""; const bodyLine = bodySchema ? ` body: ${argPrefix}.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}`);