361 lines
12 KiB
JavaScript
361 lines
12 KiB
JavaScript
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<string, unknown>";
|
|
}
|
|
|
|
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<string, unknown>;`;
|
|
}
|
|
|
|
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<string, string | number | boolean>;
|
|
query?: Record<string, string | number | boolean | undefined>;
|
|
body?: unknown;
|
|
};
|
|
|
|
export const apiOperations = {
|
|
${operationEntries}
|
|
} as const satisfies Record<string, ApiOperation>;
|
|
`.trim();
|
|
|
|
const sendFunction = `
|
|
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): 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, string | number | boolean | undefined>): 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<TResult>(operation: ApiOperation, options: RequestOptions): Promise<TResult> {
|
|
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
|
|
|
|
const headers: Record<string, string> = {
|
|
"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<TResult>;
|
|
}
|
|
`.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)}`);
|