import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import prettier from "prettier"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); const openApiPath = path.join(repoRoot, "openapi", "GameList.json"); const outputPath = path.join(repoRoot, "wwwroot", "js", "api-client.generated.js"); const requiredOperationIds = [ "GetAuthOptions", "Register", "Login", "Logout", "GetState", "GetStateEvents", "GetMe", "NextPhase", "PrevPhase", "GetMySuggestions", "CreateSuggestion", "DeleteSuggestion", "UpdateSuggestion", "GetAllSuggestions", "GetMyVotes", "UpsertVote", "SetVotesFinalized", "GetResults", "SetResultsOpen", "GetVoteStatus", "GrantJoker", "SetPlayerPhase", "SetPlayerAdmin", "DeletePlayer", "LinkSuggestions", "UnlinkSuggestions", "Reset", "FactoryReset", ]; if (!fs.existsSync(openApiPath)) { throw new Error(`OpenAPI document not found at ${openApiPath}. Build the .NET solution first.`); } const document = JSON.parse(fs.readFileSync(openApiPath, "utf8")); const operations = collectOperations(document); validateRequiredOperations(operations); const generated = renderClient(operations); const prettierConfig = (await prettier.resolveConfig(outputPath, { editorconfig: true })) ?? {}; const formatted = await prettier.format(generated, { ...prettierConfig, filepath: outputPath, }); fs.writeFileSync(outputPath, formatted, "utf8"); console.log(`Generated ${path.relative(repoRoot, outputPath)} from ${path.relative(repoRoot, openApiPath)}`); function collectOperations(openApiDocument) { const methods = ["get", "post", "put", "delete", "patch"]; const entries = []; for (const [routePath, pathItem] of Object.entries(openApiDocument.paths ?? {})) { for (const method of methods) { const operation = pathItem?.[method]; if (!operation?.operationId) continue; if (!routePath.startsWith("/api/")) continue; const pathParameters = (operation.parameters ?? []) .filter((p) => p.in === "path") .map((p) => p.name); entries.push({ operationId: operation.operationId, method: method.toUpperCase(), path: routePath, hasBody: Boolean(operation.requestBody), pathParameters, }); } } entries.sort((a, b) => a.operationId.localeCompare(b.operationId)); return entries; } function validateRequiredOperations(operationsList) { const found = new Set(operationsList.map((operation) => operation.operationId)); const missing = requiredOperationIds.filter((operationId) => !found.has(operationId)); if (missing.length > 0) { throw new Error(`OpenAPI document is missing expected operations: ${missing.join(", ")}`); } } function renderClient(operationsList) { const operationObjectLiteral = operationsList .map((operation) => { const pathParams = `[${operation.pathParameters.map((name) => `"${name}"`).join(", ")}]`; return [ ` ${operation.operationId}: {`, ` method: "${operation.method}",`, ` path: "${operation.path}",`, ` hasBody: ${operation.hasBody ? "true" : "false"},`, ` pathParameters: ${pathParams},`, " },", ].join("\n"); }) .join("\n"); const clientFunctions = requiredOperationIds .map((operationId) => { const methodName = toCamelCase(operationId); return ` ${methodName}: (options = {}) => requestOperation("${operationId}", options),`; }) .join("\n"); return `// AUTO-GENERATED FILE. DO NOT EDIT. // Source: scripts/generate-api-client.mjs and openapi/GameList.json const defaultHeaders = { "Content-Type": "application/json" }; const rawBase = document.querySelector('meta[name="app-base"]')?.content || ""; const basePath = normalizeBase(rawBase); const withBase = (routePath) => \`\${basePath}\${routePath}\`; function normalizeBase(value) { if (!value) return ""; if (!value.startsWith("/")) return \`/\${value}\`; return value.endsWith("/") ? value.slice(0, -1) : value; } function toApiError(res, fallbackMessage = \`\${res.status}\`) { const err = new Error(fallbackMessage); err.status = res.status; return err; } function buildPath(template, pathParameters = {}) { return template.replace(/{([^}]+)}/g, (_, key) => { const value = pathParameters[key]; if (value === undefined || value === null) { throw new Error(\`Missing path parameter "\${key}" for route \${template}\`); } return encodeURIComponent(String(value)); }); } async function parseApiError(res) { try { const data = await res.json(); const message = data.error || data.detail || data.title || JSON.stringify(data); return toApiError(res, message); } catch { return toApiError(res); } } export const operations = Object.freeze({ ${operationObjectLiteral} }); export function resolveOperationPath(operationId, pathParameters = {}) { const operation = operations[operationId]; if (!operation) { throw new Error(\`Unknown operationId "\${operationId}"\`); } return withBase(buildPath(operation.path, pathParameters)); } export async function requestOperation( operationId, { pathParameters = {}, body, headers = {}, raw = false, acceptStatuses = [] } = {} ) { const operation = operations[operationId]; if (!operation) { throw new Error(\`Unknown operationId "\${operationId}"\`); } const response = await fetch(resolveOperationPath(operationId, pathParameters), { method: operation.method, credentials: "same-origin", headers: { ...defaultHeaders, ...headers }, body: body === undefined ? undefined : JSON.stringify(body), }); const acceptedStatusSet = new Set(acceptStatuses); if (!response.ok && !acceptedStatusSet.has(response.status)) { throw await parseApiError(response); } if (raw) return response; if (response.status === 204) return null; return response.json(); } export const apiClient = Object.freeze({ ${clientFunctions} }); `; } function toCamelCase(value) { if (!value) return value; return `${value.charAt(0).toLowerCase()}${value.slice(1)}`; }