Migrate frontend sources to TypeScript
This commit is contained in:
7
FAQ.md
7
FAQ.md
@@ -5,7 +5,12 @@
|
|||||||
The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling:
|
The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling:
|
||||||
|
|
||||||
- API client generation from the OpenAPI contract
|
- API client generation from the OpenAPI contract
|
||||||
- basic frontend syntax/contract checks
|
- TypeScript compilation for frontend source files
|
||||||
|
- basic frontend contract checks
|
||||||
- deterministic formatting checks used by `scripts/ci-local.ps1`
|
- deterministic formatting checks used by `scripts/ci-local.ps1`
|
||||||
|
|
||||||
This keeps the first commit small while preserving CI discipline. Additional tooling can be introduced when the frontend stack is finalized.
|
This keeps the first commit small while preserving CI discipline. Additional tooling can be introduced when the frontend stack is finalized.
|
||||||
|
|
||||||
|
## Is frontend JavaScript handwritten?
|
||||||
|
|
||||||
|
No. Frontend source code lives in `RpgRoller/frontend/*.ts` and is compiled to `RpgRoller/wwwroot/*.js` for browser delivery.
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Fresh full-stack starter scaffold:
|
Fresh full-stack starter scaffold:
|
||||||
|
|
||||||
- `RpgRoller/`: ASP.NET Core backend + static frontend (`wwwroot`)
|
- `RpgRoller/`: ASP.NET Core backend + static frontend output (`wwwroot`)
|
||||||
|
- `RpgRoller/frontend/`: TypeScript frontend source
|
||||||
- `RpgRoller.Tests/`: xUnit integration-heavy test project
|
- `RpgRoller.Tests/`: xUnit integration-heavy test project
|
||||||
- `RpgRoller.sln`: solution used by local CI script
|
- `RpgRoller.sln`: solution used by local CI script
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ Fresh full-stack starter scaffold:
|
|||||||
## Frontend Tooling
|
## Frontend Tooling
|
||||||
|
|
||||||
- OpenAPI contract: `openapi/RpgRoller.json`
|
- OpenAPI contract: `openapi/RpgRoller.json`
|
||||||
- API client generation: `npm run generate:api-client`
|
- TypeScript build output config: `tsconfig.frontend.json`
|
||||||
|
- API client generation + frontend compile: `npm run generate:api-client`
|
||||||
- Frontend lint checks: `npm run lint`
|
- Frontend lint checks: `npm run lint`
|
||||||
- Frontend format checks: `npm run format:check`
|
- Frontend format checks: `npm run format:check`
|
||||||
|
|
||||||
|
|||||||
53
RpgRoller/frontend/app.ts
Normal file
53
RpgRoller/frontend/app.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { getHealth, rollDice } from "./generated/api-client.js";
|
||||||
|
|
||||||
|
const healthElementRaw = document.getElementById("health");
|
||||||
|
const resultElementRaw = document.getElementById("result");
|
||||||
|
const formElementRaw = document.getElementById("roll-form");
|
||||||
|
const sidesInputRaw = document.getElementById("sides");
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(healthElementRaw instanceof HTMLElement) ||
|
||||||
|
!(resultElementRaw instanceof HTMLElement) ||
|
||||||
|
!(formElementRaw instanceof HTMLFormElement) ||
|
||||||
|
!(sidesInputRaw instanceof HTMLInputElement)
|
||||||
|
) {
|
||||||
|
throw new Error("Required UI elements are missing from index.html.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthElement = healthElementRaw;
|
||||||
|
const resultElement = resultElementRaw;
|
||||||
|
const formElement = formElementRaw;
|
||||||
|
const sidesInput = sidesInputRaw;
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHealth(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const health = await getHealth();
|
||||||
|
healthElement.textContent = `API status: ${health.status}`;
|
||||||
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
|
healthElement.textContent = `API status check failed: ${errorMessage(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formElement.addEventListener("submit", async (event: SubmitEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const sides = Number.parseInt(sidesInput.value, 10);
|
||||||
|
try {
|
||||||
|
const roll = await rollDice(sides);
|
||||||
|
resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
|
||||||
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
|
resultElement.textContent = `Roll failed: ${errorMessage(error)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshHealth();
|
||||||
57
RpgRoller/frontend/generated/api-client.ts
Normal file
57
RpgRoller/frontend/generated/api-client.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/* This file is generated by scripts/generate-api-client.mjs. */
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RollResponse {
|
||||||
|
sides: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiOperation = {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiOperations = {
|
||||||
|
getHealth: { method: "GET", path: "/api/health" },
|
||||||
|
rollDice: { method: "GET", path: "/api/roll/{sides}" }
|
||||||
|
} as const satisfies Record<string, ApiOperation>;
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(resolvedPath, {
|
||||||
|
method: operation.method,
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<TResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHealth(): Promise<HealthResponse> {
|
||||||
|
return send<HealthResponse>(apiOperations.getHealth, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollDice(sides: number): Promise<RollResponse> {
|
||||||
|
return send<RollResponse>(apiOperations.rollDice, { sides: sides });
|
||||||
|
}
|
||||||
@@ -1,31 +1,42 @@
|
|||||||
import { getHealth, rollDice } from "./generated/api-client.js";
|
import { getHealth, rollDice } from "./generated/api-client.js";
|
||||||
|
const healthElementRaw = document.getElementById("health");
|
||||||
const healthElement = document.getElementById("health");
|
const resultElementRaw = document.getElementById("result");
|
||||||
const resultElement = document.getElementById("result");
|
const formElementRaw = document.getElementById("roll-form");
|
||||||
const formElement = document.getElementById("roll-form");
|
const sidesInputRaw = document.getElementById("sides");
|
||||||
const sidesInput = document.getElementById("sides");
|
if (!(healthElementRaw instanceof HTMLElement) ||
|
||||||
|
!(resultElementRaw instanceof HTMLElement) ||
|
||||||
|
!(formElementRaw instanceof HTMLFormElement) ||
|
||||||
|
!(sidesInputRaw instanceof HTMLInputElement)) {
|
||||||
|
throw new Error("Required UI elements are missing from index.html.");
|
||||||
|
}
|
||||||
|
const healthElement = healthElementRaw;
|
||||||
|
const resultElement = resultElementRaw;
|
||||||
|
const formElement = formElementRaw;
|
||||||
|
const sidesInput = sidesInputRaw;
|
||||||
|
function errorMessage(error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
async function refreshHealth() {
|
async function refreshHealth() {
|
||||||
try {
|
try {
|
||||||
const health = await getHealth();
|
const health = await getHealth();
|
||||||
healthElement.textContent = `API status: ${health.status}`;
|
healthElement.textContent = `API status: ${health.status}`;
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
healthElement.textContent = `API status check failed: ${error.message}`;
|
healthElement.textContent = `API status check failed: ${errorMessage(error)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
formElement.addEventListener("submit", async (event) => {
|
formElement.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const sides = Number.parseInt(sidesInput.value, 10);
|
const sides = Number.parseInt(sidesInput.value, 10);
|
||||||
try {
|
try {
|
||||||
const roll = await rollDice(sides);
|
const roll = await rollDice(sides);
|
||||||
resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
|
resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
resultElement.textContent = `Roll failed: ${error.message}`;
|
resultElement.textContent = `Roll failed: ${errorMessage(error)}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await refreshHealth();
|
await refreshHealth();
|
||||||
|
|||||||
@@ -1,37 +1,31 @@
|
|||||||
/* This file is generated by scripts/generate-api-client.mjs. */
|
/* This file is generated by scripts/generate-api-client.mjs. */
|
||||||
|
export const apiOperations = {
|
||||||
export { apiOperations };
|
|
||||||
|
|
||||||
const apiOperations = {
|
|
||||||
getHealth: { method: "GET", path: "/api/health" },
|
getHealth: { method: "GET", path: "/api/health" },
|
||||||
rollDice: { method: "GET", path: "/api/roll/{sides}" }
|
rollDice: { method: "GET", path: "/api/roll/{sides}" }
|
||||||
};
|
};
|
||||||
|
|
||||||
async function send(operation, pathParams = {}) {
|
async function send(operation, pathParams = {}) {
|
||||||
let resolvedPath = operation.path;
|
let resolvedPath = operation.path;
|
||||||
for (const [key, value] of Object.entries(pathParams)) {
|
for (const [key, value] of Object.entries(pathParams)) {
|
||||||
resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value)));
|
resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(resolvedPath, {
|
const response = await fetch(resolvedPath, {
|
||||||
method: operation.method,
|
method: operation.method,
|
||||||
headers: {
|
headers: {
|
||||||
"Accept": "application/json"
|
"Accept": "application/json"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
|
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
|
||||||
throw new Error(errorPayload.error ?? `Request failed with status ${response.status}`);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHealth() {
|
export async function getHealth() {
|
||||||
return send(apiOperations.getHealth, {});
|
return send(apiOperations.getHealth, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rollDice(sides) {
|
export async function rollDice(sides) {
|
||||||
return send(apiOperations.rollDice, { sides: sides });
|
return send(apiOperations.rollDice, { sides: sides });
|
||||||
}
|
}
|
||||||
|
|||||||
4
TECH.md
4
TECH.md
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
- Root solution: `RpgRoller.sln`
|
- Root solution: `RpgRoller.sln`
|
||||||
- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend)
|
- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend)
|
||||||
|
- Frontend source: `RpgRoller/frontend` (TypeScript)
|
||||||
- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
|
- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
|
||||||
- OpenAPI source: `openapi/RpgRoller.json`
|
- OpenAPI source: `openapi/RpgRoller.json`
|
||||||
- Generated client target: `RpgRoller/wwwroot/generated/api-client.js`
|
- Generated client source: `RpgRoller/frontend/generated/api-client.ts`
|
||||||
|
- Generated client output: `RpgRoller/wwwroot/generated/api-client.js`
|
||||||
- Local CI parity entrypoint: `scripts/ci-local.ps1`
|
- Local CI parity entrypoint: `scripts/ci-local.ps1`
|
||||||
|
|
||||||
## 1) Stack and baseline choices
|
## 1) Stack and baseline choices
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -6,7 +6,24 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rpgroller",
|
"name": "rpgroller",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate:api-client": "node ./scripts/generate-api-client.mjs",
|
"build:frontend": "tsc -p ./tsconfig.frontend.json",
|
||||||
"lint": "node ./scripts/lint-frontend.mjs",
|
"generate:api-client": "node ./scripts/generate-api-client.mjs && npm run build:frontend",
|
||||||
|
"lint": "tsc -p ./tsconfig.frontend.json --noEmit && node ./scripts/lint-frontend.mjs",
|
||||||
"format:check": "node ./scripts/format-check.mjs"
|
"format:check": "node ./scripts/format-check.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const repoRoot = path.resolve(scriptDirectory, "..");
|
const repoRoot = path.resolve(scriptDirectory, "..");
|
||||||
|
|
||||||
const directoriesToScan = [
|
const directoriesToScan = [
|
||||||
|
path.join(repoRoot, "RpgRoller", "frontend"),
|
||||||
path.join(repoRoot, "RpgRoller", "wwwroot"),
|
path.join(repoRoot, "RpgRoller", "wwwroot"),
|
||||||
path.join(repoRoot, "openapi")
|
path.join(repoRoot, "openapi")
|
||||||
];
|
];
|
||||||
|
|
||||||
const filesToScan = [
|
const filesToScan = [
|
||||||
|
path.join(repoRoot, "package.json"),
|
||||||
|
path.join(repoRoot, "tsconfig.frontend.json"),
|
||||||
path.join(repoRoot, "scripts", "generate-api-client.mjs"),
|
path.join(repoRoot, "scripts", "generate-api-client.mjs"),
|
||||||
path.join(repoRoot, "scripts", "lint-frontend.mjs"),
|
path.join(repoRoot, "scripts", "lint-frontend.mjs"),
|
||||||
path.join(repoRoot, "scripts", "format-check.mjs")
|
path.join(repoRoot, "scripts", "format-check.mjs")
|
||||||
|
|||||||
@@ -5,15 +5,119 @@ import { fileURLToPath } from "node:url";
|
|||||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(scriptDirectory, "..");
|
const repoRoot = path.resolve(scriptDirectory, "..");
|
||||||
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
|
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) {
|
function escapePathSegment(segment) {
|
||||||
return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
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 = [];
|
const operations = [];
|
||||||
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
|
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 ?? {})) {
|
for (const [method, operation] of Object.entries(pathItem ?? {})) {
|
||||||
if (operation === null || typeof operation !== "object") {
|
if (operation === null || typeof operation !== "object") {
|
||||||
continue;
|
continue;
|
||||||
@@ -26,7 +130,9 @@ function collectOperations(document) {
|
|||||||
operations.push({
|
operations.push({
|
||||||
operationId: operation.operationId,
|
operationId: operation.operationId,
|
||||||
method: method.toUpperCase(),
|
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));
|
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
|
const operationEntries = operations
|
||||||
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`)
|
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`)
|
||||||
.join(",\n");
|
.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 = `
|
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;
|
let resolvedPath = operation.path;
|
||||||
for (const [key, value] of Object.entries(pathParams)) {
|
for (const [key, value] of Object.entries(pathParams)) {
|
||||||
resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
|
resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
|
||||||
@@ -60,33 +237,41 @@ async function send(operation, pathParams = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
|
const errorPayload: unknown = await response.json().catch(() => ({ error: "Unknown API error." }));
|
||||||
throw new Error(errorPayload.error ?? \`Request failed with status \${response.status}\`);
|
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();
|
`.trim();
|
||||||
|
|
||||||
const exports = operations
|
const exports = operations
|
||||||
.map((operation) => {
|
.map((operation) => {
|
||||||
const params = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
|
const params = operation.parameters;
|
||||||
const signature = params.length === 0 ? "" : params.join(", ");
|
const signature = params.length === 0 ? "" : params.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", ");
|
||||||
const pathParams = params.length === 0
|
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");
|
.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 openApiText = await readFile(openApiPath, "utf8");
|
||||||
const document = JSON.parse(openApiText);
|
const document = JSON.parse(openApiText);
|
||||||
const operations = collectOperations(document);
|
const components = document.components ?? {};
|
||||||
const clientSource = buildClientSource(operations);
|
const operations = collectOperations(document, components);
|
||||||
|
const schemaTypes = buildSchemaTypes(document);
|
||||||
|
const clientSource = buildClientSource(operations, schemaTypes);
|
||||||
|
|
||||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
await writeFile(outputPath, clientSource, "utf8");
|
await writeFile(outputPath, clientSource, "utf8");
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(scriptDirectory, "..");
|
const repoRoot = path.resolve(scriptDirectory, "..");
|
||||||
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
|
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
|
||||||
const appJsPath = path.join(repoRoot, "RpgRoller", "wwwroot", "app.js");
|
const appTsPath = path.join(repoRoot, "RpgRoller", "frontend", "app.ts");
|
||||||
const generatedClientPath = path.join(repoRoot, "RpgRoller", "wwwroot", "generated", "api-client.js");
|
const generatedClientPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts");
|
||||||
|
|
||||||
const openApi = JSON.parse(await readFile(openApiPath, "utf8"));
|
const openApi = JSON.parse(await readFile(openApiPath, "utf8"));
|
||||||
const generatedClient = await readFile(generatedClientPath, "utf8");
|
const generatedClient = await readFile(generatedClientPath, "utf8");
|
||||||
|
const appSource = await readFile(appTsPath, "utf8");
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
const appSyntaxCheck = spawnSync(process.execPath, ["--check", appJsPath], { encoding: "utf8" });
|
if (!appSource.includes("from \"./generated/api-client.js\"")) {
|
||||||
if (appSyntaxCheck.status !== 0) {
|
errors.push("Frontend app.ts must import the generated api-client module.");
|
||||||
errors.push(`Syntax error in ${path.relative(repoRoot, appJsPath)}:\n${appSyntaxCheck.stderr}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) {
|
for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) {
|
||||||
|
|||||||
20
tsconfig.frontend.json
Normal file
20
tsconfig.frontend.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"strict": true,
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"rootDir": "./RpgRoller/frontend",
|
||||||
|
"outDir": "./RpgRoller/wwwroot",
|
||||||
|
"newLine": "lf"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"RpgRoller/frontend/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user