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, 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, true); 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, 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, true)).join(" | "); } if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) { return schema.anyOf.map((option) => toTypeScriptType(option, true)).join(" | "); } return "unknown"; } function schemaToInterface(name, schema) { const required = new Set(Array.isArray(schema.required) ? schema.required : []); const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => { const typeName = toTypeScriptType(propertySchema, 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 collectParameters(pathItem, operation) { const rawParameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])]; const deduped = []; const keys = new Set(); for (const parameter of rawParameters) { if (!parameter || typeof parameter !== "object" || typeof parameter.name !== "string") { continue; } const key = `${parameter.in}:${parameter.name}`; if (keys.has(key)) { continue; } keys.add(key); const inferredType = mapSimpleType(parameter.schema) ?? "string"; deduped.push({ name: parameter.name, location: parameter.in, required: parameter.required === true, type: inferredType }); } return deduped; } function resolveRequestBodyType(operation) { const bodySchema = operation.requestBody?.content?.["application/json"]?.schema; if (!bodySchema) { return null; } return { type: toTypeScriptType(bodySchema, true), required: operation.requestBody.required === true }; } function resolveResponse(operation) { const responses = operation.responses ?? {}; const successCodes = Object.keys(responses) .filter((code) => code.startsWith("2")) .sort(); if (successCodes.length === 0) { return { type: "void", expectsJson: false }; } for (const code of successCodes) { const jsonSchema = responses[code]?.content?.["application/json"]?.schema; if (jsonSchema) { return { type: toTypeScriptType(jsonSchema), expectsJson: true }; } if (code === "204") { return { type: "void", expectsJson: false }; } } return { type: "void", expectsJson: false }; } function collectOperations(document) { const operations = []; for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) { for (const [method, operation] of Object.entries(pathItem ?? {})) { if (operation === null || typeof operation !== "object" || typeof operation.operationId !== "string") { continue; } if (operation.operationId.length === 0) { throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`); } const parameters = collectParameters(pathItem, operation); const pathParameters = parameters.filter((parameter) => parameter.location === "path"); const queryParameters = parameters.filter((parameter) => parameter.location === "query"); const requestBody = resolveRequestBodyType(operation); const response = resolveResponse(operation); operations.push({ operationId: operation.operationId, method: method.toUpperCase(), path: pathKey, pathParameters, queryParameters, requestBody, response }); } } 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 buildSchemaTypes(document) { const schemas = document.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); }); return declarations.join("\n\n"); } function buildFunctionSource(operation) { const pathParameters = operation.pathParameters; const queryParameters = operation.queryParameters; const body = operation.requestBody; const parts = []; if (pathParameters.length > 0) { parts.push(...pathParameters.map((parameter) => `${parameter.name}: ${parameter.type}`)); } if (body) { const optional = body.required ? "" : "?"; parts.push(`body${optional}: ${body.type}`); } if (queryParameters.length > 0) { const queryType = queryParameters .map((parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type};`) .join(" "); parts.push(`query: { ${queryType} }`); } const signature = parts.join(", "); const pathParameterAssignment = pathParameters.length === 0 ? "{}" : `{ ${pathParameters.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`; const bodyArgument = body ? "body" : "undefined"; const queryArgument = queryParameters.length > 0 ? "query" : "undefined"; return `export async function ${operation.operationId}(${signature}): Promise<${operation.response.type}> {\n return send<${operation.response.type}>(apiOperations.${operation.operationId}, {\n pathParams: ${pathParameterAssignment},\n query: ${queryArgument},\n body: ${bodyArgument}\n });\n}`; } function buildClientSource(operations, schemaTypes) { const operationEntries = operations .map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}", expectsJson: ${operation.response.expectsJson ? "true" : "false"} }`) .join(",\n"); const helper = ` type ApiOperation = { method: string; path: string; expectsJson: boolean; }; type RequestOptions = { pathParams?: Record; query?: Record; body?: unknown; }; export const apiOperations = { ${operationEntries} } as const satisfies Record; `.trim(); const sendFunction = ` function withPathParams(pathTemplate: string, pathParams: Record): string { let pathValue = pathTemplate; for (const [key, value] of Object.entries(pathParams)) { pathValue = pathValue.replace(\`{\${key}}\`, encodeURIComponent(String(value))); } return pathValue; } function withQuery(pathValue: string, query: Record): string { const entries = Object.entries(query).filter(([, value]) => value !== undefined); if (entries.length === 0) { return pathValue; } const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString(); return queryString.length === 0 ? pathValue : \`\${pathValue}?\${queryString}\`; } async function send(operation: ApiOperation, options: RequestOptions): Promise { const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {}); const headers: Record = { "Accept": "application/json" }; let body: string | undefined; if (options.body !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(options.body); } const response = await fetch(resolvedPath, { method: operation.method, headers, body }); 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}\`); } if (!operation.expectsJson) { return undefined as TResult; } return response.json() as Promise; } `.trim(); const exports = operations.map(buildFunctionSource).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 operations = collectOperations(document); 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)}`);