Migrate frontend sources to TypeScript
This commit is contained in:
@@ -5,15 +5,119 @@ 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", "wwwroot", "generated", "api-client.js");
|
||||
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 collectOperations(document) {
|
||||
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, components, 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, components);
|
||||
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, components, 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, components, true)).join(" | ");
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
||||
return schema.anyOf.map((option) => toTypeScriptType(option, components, true)).join(" | ");
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function schemaToInterface(name, schema, components) {
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => {
|
||||
const typeName = toTypeScriptType(propertySchema, components, 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 collectOperations(document, components) {
|
||||
const operations = [];
|
||||
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
|
||||
const pathLevelParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : [];
|
||||
|
||||
for (const [method, operation] of Object.entries(pathItem ?? {})) {
|
||||
if (operation === null || typeof operation !== "object") {
|
||||
continue;
|
||||
@@ -26,7 +130,9 @@ function collectOperations(document) {
|
||||
operations.push({
|
||||
operationId: operation.operationId,
|
||||
method: method.toUpperCase(),
|
||||
path: pathKey
|
||||
path: pathKey,
|
||||
parameters: collectPathParameters([...pathLevelParameters, ...(operation.parameters ?? [])]),
|
||||
responseType: resolveResponseType(operation, components)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,15 +144,86 @@ function collectOperations(document) {
|
||||
return operations.sort((left, right) => left.operationId.localeCompare(right.operationId));
|
||||
}
|
||||
|
||||
function buildClientSource(operations) {
|
||||
function collectPathParameters(parameters) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
|
||||
for (const parameter of parameters) {
|
||||
if (!parameter || parameter.in !== "path" || typeof parameter.name !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(parameter.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(parameter.name);
|
||||
|
||||
const simpleType = mapSimpleType(parameter.schema);
|
||||
result.push({
|
||||
name: parameter.name,
|
||||
type: simpleType ?? "string"
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveResponseType(operation, components) {
|
||||
const responses = operation.responses ?? {};
|
||||
const successCodes = Object.keys(responses)
|
||||
.filter((code) => code.startsWith("2"))
|
||||
.sort();
|
||||
|
||||
if (successCodes.length === 0) {
|
||||
return "void";
|
||||
}
|
||||
|
||||
for (const code of successCodes) {
|
||||
const content = responses[code]?.content?.["application/json"];
|
||||
const schema = content?.schema;
|
||||
if (schema) {
|
||||
return toTypeScriptType(schema, components);
|
||||
}
|
||||
}
|
||||
|
||||
return "void";
|
||||
}
|
||||
|
||||
function buildSchemaTypes(document) {
|
||||
const components = document.components ?? {};
|
||||
const schemas = 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, components);
|
||||
});
|
||||
|
||||
return declarations.join("\n\n");
|
||||
}
|
||||
|
||||
function buildClientSource(operations, schemaTypes) {
|
||||
const operationEntries = operations
|
||||
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`)
|
||||
.join(",\n");
|
||||
|
||||
const helper = `const apiOperations = {\n${operationEntries}\n};\n`;
|
||||
const helper = `
|
||||
type ApiOperation = {
|
||||
method: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const apiOperations = {
|
||||
${operationEntries}
|
||||
} as const satisfies Record<string, ApiOperation>;
|
||||
`.trim();
|
||||
|
||||
const sendFunction = `
|
||||
async function send(operation, pathParams = {}) {
|
||||
async function send<TResult>(operation: ApiOperation, pathParams: Record<string, string | number | boolean> = {}): Promise<TResult> {
|
||||
let resolvedPath = operation.path;
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
|
||||
@@ -60,33 +237,41 @@ async function send(operation, pathParams = {}) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
|
||||
throw new Error(errorPayload.error ?? \`Request failed with status \${response.status}\`);
|
||||
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}\`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return response.json() as Promise<TResult>;
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const exports = operations
|
||||
.map((operation) => {
|
||||
const params = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
|
||||
const signature = params.length === 0 ? "" : params.join(", ");
|
||||
const params = operation.parameters;
|
||||
const signature = params.length === 0 ? "" : params.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", ");
|
||||
const pathParams = params.length === 0
|
||||
? "{}"
|
||||
: `{ ${params.map((name) => `${name}: ${name}`).join(", ")} }`;
|
||||
: `{ ${params.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`;
|
||||
|
||||
return `export async function ${operation.operationId}(${signature}) {\n return send(apiOperations.${operation.operationId}, ${pathParams});\n}`;
|
||||
const responseType = operation.responseType ?? "unknown";
|
||||
|
||||
return `export async function ${operation.operationId}(${signature}): Promise<${responseType}> {\n return send<${responseType}>(apiOperations.${operation.operationId}, ${pathParams});\n}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return `/* This file is generated by scripts/generate-api-client.mjs. */\n\nexport { apiOperations };\n\n${helper}\n${sendFunction}\n\n${exports}\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 clientSource = buildClientSource(operations);
|
||||
const components = document.components ?? {};
|
||||
const operations = collectOperations(document, components);
|
||||
const schemaTypes = buildSchemaTypes(document);
|
||||
const clientSource = buildClientSource(operations, schemaTypes);
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, clientSource, "utf8");
|
||||
|
||||
Reference in New Issue
Block a user