import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDirectory, ".."); const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json"); const outputPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts"); function pascalCase(value) { return value .replace(/[^a-zA-Z0-9]+/g, " ") .trim() .split(/\s+/) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(""); } function escapePathSegment(segment) { return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); } function mapSimpleType(schema) { switch (schema?.type) { case "integer": case "number": return "number"; case "boolean": return "boolean"; case "string": return "string"; default: return null; } } function schemaRefName(schema) { if (typeof schema?.$ref !== "string") { return null; } const refPrefix = "#/components/schemas/"; if (!schema.$ref.startsWith(refPrefix)) { throw new Error(`Unsupported schema ref: ${schema.$ref}`); } return schema.$ref.substring(refPrefix.length); } function toTypeScriptType(schema, components, forProperty = false) { if (!schema || typeof schema !== "object") { return "unknown"; } const refName = schemaRefName(schema); if (refName !== null) { return refName; } const simpleType = mapSimpleType(schema); if (simpleType !== null) { return simpleType; } if (schema.type === "array") { const itemType = toTypeScriptType(schema.items, components); return `Array<${itemType}>`; } if (schema.type === "object") { const propertyEntries = Object.entries(schema.properties ?? {}); if (propertyEntries.length === 0) { return "Record"; } const required = new Set(Array.isArray(schema.required) ? schema.required : []); const fields = propertyEntries.map(([name, propertySchema]) => { const typeName = toTypeScriptType(propertySchema, components, true); const optional = required.has(name) ? "" : "?"; return `${name}${optional}: ${typeName};`; }); if (forProperty) { return `{ ${fields.join(" ")} }`; } return `{\n${fields.map((field) => ` ${field}`).join("\n")}\n}`; } if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) { return schema.oneOf.map((option) => toTypeScriptType(option, components, true)).join(" | "); } if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) { return schema.anyOf.map((option) => toTypeScriptType(option, components, true)).join(" | "); } return "unknown"; } function schemaToInterface(name, schema, components) { const required = new Set(Array.isArray(schema.required) ? schema.required : []); const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => { const typeName = toTypeScriptType(propertySchema, components, true); const optional = required.has(propertyName) ? "" : "?"; return ` ${propertyName}${optional}: ${typeName};`; }); if (fields.length === 0) { return `export type ${name} = Record;`; } return `export interface ${name} {\n${fields.join("\n")}\n}`; } function collectOperations(document, components) { const operations = []; for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) { const pathLevelParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : []; for (const [method, operation] of Object.entries(pathItem ?? {})) { if (operation === null || typeof operation !== "object") { continue; } if (typeof operation.operationId !== "string" || operation.operationId.length === 0) { throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`); } operations.push({ operationId: operation.operationId, method: method.toUpperCase(), path: pathKey, parameters: collectPathParameters([...pathLevelParameters, ...(operation.parameters ?? [])]), responseType: resolveResponseType(operation, components) }); } } if (operations.length === 0) { throw new Error("OpenAPI document does not define any operations."); } return operations.sort((left, right) => left.operationId.localeCompare(right.operationId)); } function collectPathParameters(parameters) { const seen = new Set(); const result = []; for (const parameter of parameters) { if (!parameter || parameter.in !== "path" || typeof parameter.name !== "string") { continue; } if (seen.has(parameter.name)) { continue; } seen.add(parameter.name); const simpleType = mapSimpleType(parameter.schema); result.push({ name: parameter.name, type: simpleType ?? "string" }); } return result; } function resolveResponseType(operation, components) { const responses = operation.responses ?? {}; const successCodes = Object.keys(responses) .filter((code) => code.startsWith("2")) .sort(); if (successCodes.length === 0) { return "void"; } for (const code of successCodes) { const content = responses[code]?.content?.["application/json"]; const schema = content?.schema; if (schema) { return toTypeScriptType(schema, components); } } return "void"; } function buildSchemaTypes(document) { const components = document.components ?? {}; const schemas = components.schemas ?? {}; const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right)); const declarations = schemaEntries.map(([name, schema]) => { if (!schema || typeof schema !== "object") { return `export type ${name} = unknown;`; } return schemaToInterface(name, schema, components); }); return declarations.join("\n\n"); } function buildClientSource(operations, schemaTypes) { const operationEntries = operations .map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`) .join(",\n"); const helper = ` type ApiOperation = { method: string; path: string; }; export const apiOperations = { ${operationEntries} } as const satisfies Record; `.trim(); const sendFunction = ` async function send(operation: ApiOperation, pathParams: Record = {}): Promise { let resolvedPath = operation.path; for (const [key, value] of Object.entries(pathParams)) { resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value))); } const response = await fetch(resolvedPath, { method: operation.method, headers: { "Accept": "application/json" } }); if (!response.ok) { const errorPayload: unknown = await response.json().catch(() => ({ error: "Unknown API error." })); if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") { throw new Error(errorPayload.error); } throw new Error(\`Request failed with status \${response.status}\`); } return response.json() as Promise; } `.trim(); const exports = operations .map((operation) => { const params = operation.parameters; const signature = params.length === 0 ? "" : params.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", "); const pathParams = params.length === 0 ? "{}" : `{ ${params.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`; const responseType = operation.responseType ?? "unknown"; return `export async function ${operation.operationId}(${signature}): Promise<${responseType}> {\n return send<${responseType}>(apiOperations.${operation.operationId}, ${pathParams});\n}`; }) .join("\n\n"); return `/* This file is generated by scripts/generate-api-client.mjs. */\n\n${schemaTypes}\n\n${helper}\n\n${sendFunction}\n\n${exports}\n`; } const openApiText = await readFile(openApiPath, "utf8"); const document = JSON.parse(openApiText); const components = document.components ?? {}; const operations = collectOperations(document, components); const schemaTypes = buildSchemaTypes(document); const clientSource = buildClientSource(operations, schemaTypes); await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile(outputPath, clientSource, "utf8"); console.log(`Generated API client: ${path.relative(repoRoot, outputPath)}`);