210 lines
6.5 KiB
JavaScript
210 lines
6.5 KiB
JavaScript
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)}`;
|
|
}
|