Add OpenAPI contract and generated frontend client
This commit is contained in:
209
scripts/generate-api-client.mjs
Normal file
209
scripts/generate-api-client.mjs
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user