Add OpenAPI contract and generated frontend client

This commit is contained in:
2026-02-18 21:25:07 +01:00
parent e55a1b01f4
commit 1802fd6607
19 changed files with 1509 additions and 126 deletions

View File

@@ -0,0 +1,209 @@
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)}`;
}