Scaffold full-stack solution and CI baseline

This commit is contained in:
2026-02-24 21:27:51 +01:00
parent f3e3178f2f
commit d9f0c7b7ac
27 changed files with 853 additions and 1 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Build outputs
bin/
obj/
out/
artifacts/
# IDE
.vs/
.vscode/
node_modules/
# User secrets / configs
appsettings.Development.json
scripts/deploy-ftp.profile.psd1
*.user
*.suo
# Logs
*.log
# Test results / coverage artifacts
TestResults/
coverage.cobertura.xml
# SQLite data
App_Data/
*.db
# OS cruft
Thumbs.db
Desktop.ini
Properties/launchSettings.json

11
FAQ.md Normal file
View File

@@ -0,0 +1,11 @@
# FAQ
## Why does this starter use custom frontend lint/format scripts instead of heavy npm dependencies?
The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling:
- API client generation from the OpenAPI contract
- basic frontend syntax/contract checks
- 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.

43
README.md Normal file
View File

@@ -0,0 +1,43 @@
# RpgRoller
Fresh full-stack starter scaffold:
- `RpgRoller/`: ASP.NET Core backend + static frontend (`wwwroot`)
- `RpgRoller.Tests/`: xUnit integration-heavy test project
- `RpgRoller.sln`: solution used by local CI script
## Prerequisites
- .NET SDK 10.0+
- Node.js 22+ and npm
- PowerShell 7+
## Local Development
1. Run the local CI parity script:
```powershell
pwsh ./scripts/ci-local.ps1
```
2. Start the backend:
```powershell
dotnet run --project RpgRoller/RpgRoller.csproj
```
3. Open `http://localhost:5000` (or the port shown in the console).
## Frontend Tooling
- OpenAPI contract: `openapi/RpgRoller.json`
- API client generation: `npm run generate:api-client`
- Frontend lint checks: `npm run lint`
- Frontend format checks: `npm run format:check`
## Test and Coverage
- Tests:
```powershell
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
```
- Coverage gate:
```powershell
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
```

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RpgRoller\RpgRoller.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,89 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Tests;
public sealed class UnitTest1 : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient m_Client;
public UnitTest1(WebApplicationFactory<Program> factory)
{
m_Client = factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IDiceRoller>();
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(7));
})).CreateClient();
}
[Fact]
public async Task GetHealth_ReturnsOkPayload()
{
var response = await m_Client.GetAsync("/api/health");
var payload = await response.Content.ReadFromJsonAsync<HealthResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(payload);
Assert.Equal("ok", payload.Status);
}
[Theory]
[InlineData(1, "Dice must have at least 2 sides.")]
[InlineData(1001, "Dice must have at most 1000 sides.")]
public async Task Roll_WithInvalidSides_ReturnsBadRequest(int sides, string expectedError)
{
var response = await m_Client.GetAsync($"/api/roll/{sides}");
var payload = await response.Content.ReadFromJsonAsync<ApiError>();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.NotNull(payload);
Assert.Equal(expectedError, payload.Error);
}
[Theory]
[InlineData(2)]
[InlineData(1000)]
public async Task Roll_WithValidSides_ReturnsExpectedResult(int sides)
{
var response = await m_Client.GetAsync($"/api/roll/{sides}");
var payload = await response.Content.ReadFromJsonAsync<RollResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(payload);
Assert.Equal(sides, payload.Sides);
Assert.Equal(Math.Min(7, sides), payload.Value);
}
[Fact]
public void RandomDiceRoller_ProducesValueWithinRange()
{
var roller = new RandomDiceRoller();
for (var i = 0; i < 200; i += 1)
{
var value = roller.Roll(6);
Assert.InRange(value, 1, 6);
}
}
private sealed class FixedDiceRoller : IDiceRoller
{
private readonly int m_Result;
public FixedDiceRoller(int result)
{
m_Result = result;
}
public int Roll(int sides)
{
return Math.Min(m_Result, sides);
}
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>cobertura</Format>
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>

48
RpgRoller.sln Normal file
View File

@@ -0,0 +1,48 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RpgRoller", "RpgRoller\RpgRoller.csproj", "{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RpgRoller.Tests", "RpgRoller.Tests\RpgRoller.Tests.csproj", "{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|x64.ActiveCfg = Debug|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|x64.Build.0 = Debug|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|x86.ActiveCfg = Debug|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Debug|x86.Build.0 = Debug|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|Any CPU.ActiveCfg = Release|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|Any CPU.Build.0 = Release|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|x64.ActiveCfg = Release|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|x64.Build.0 = Release|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|x86.ActiveCfg = Release|Any CPU
{67A3A7CF-91FF-47EA-B7E4-1478F6B0F075}.Release|x86.Build.0 = Release|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|x64.ActiveCfg = Debug|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|x64.Build.0 = Debug|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|x86.ActiveCfg = Debug|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Debug|x86.Build.0 = Debug|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|Any CPU.Build.0 = Release|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|x64.ActiveCfg = Release|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|x64.Build.0 = Release|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|x86.ActiveCfg = Release|Any CPU
{D84D7DFC-5EBF-4731-AE40-FD9DF9C6E6EC}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

2
RpgRoller.slnx Normal file
View File

@@ -0,0 +1,2 @@
<Solution>
</Solution>

View File

@@ -0,0 +1,5 @@
namespace RpgRoller.Contracts;
public sealed record HealthResponse(string Status);
public sealed record RollResponse(int Sides, int Value);
public sealed record ApiError(string Error);

31
RpgRoller/Program.cs Normal file
View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts;
using RpgRoller.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IDiceRoller, RandomDiceRoller>();
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet("/api/health", () => TypedResults.Ok(new HealthResponse("ok")));
app.MapGet(
"/api/roll/{sides:int}",
Results<Ok<RollResponse>, BadRequest<ApiError>> (int sides, IDiceRoller diceRoller) =>
{
var validationError = DiceRules.ValidateSides(sides);
if (validationError is not null)
{
return TypedResults.BadRequest(new ApiError(validationError));
}
var value = diceRoller.Roll(sides);
return TypedResults.Ok(new RollResponse(sides, value));
});
app.Run();
public partial class Program;

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5175",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7271;http://localhost:5175",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,19 @@
namespace RpgRoller.Services;
public static class DiceRules
{
public static string? ValidateSides(int sides)
{
if (sides < 2)
{
return "Dice must have at least 2 sides.";
}
if (sides > 1000)
{
return "Dice must have at most 1000 sides.";
}
return null;
}
}

View File

@@ -0,0 +1,6 @@
namespace RpgRoller.Services;
public interface IDiceRoller
{
int Roll(int sides);
}

View File

@@ -0,0 +1,9 @@
namespace RpgRoller.Services;
public sealed class RandomDiceRoller : IDiceRoller
{
public int Roll(int sides)
{
return Random.Shared.Next(1, sides + 1);
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

31
RpgRoller/wwwroot/app.js Normal file
View File

@@ -0,0 +1,31 @@
import { getHealth, rollDice } from "./generated/api-client.js";
const healthElement = document.getElementById("health");
const resultElement = document.getElementById("result");
const formElement = document.getElementById("roll-form");
const sidesInput = document.getElementById("sides");
async function refreshHealth() {
try {
const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`;
}
catch (error) {
healthElement.textContent = `API status check failed: ${error.message}`;
}
}
formElement.addEventListener("submit", async (event) => {
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) {
resultElement.textContent = `Roll failed: ${error.message}`;
}
});
await refreshHealth();

View File

@@ -0,0 +1,37 @@
/* This file is generated by scripts/generate-api-client.mjs. */
export { apiOperations };
const apiOperations = {
getHealth: { method: "GET", path: "/api/health" },
rollDice: { method: "GET", path: "/api/roll/{sides}" }
};
async function send(operation, pathParams = {}) {
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 = await response.json().catch(() => ({ error: "Unknown API error." }));
throw new Error(errorPayload.error ?? `Request failed with status ${response.status}`);
}
return response.json();
}
export async function getHealth() {
return send(apiOperations.getHealth, {});
}
export async function rollDice(sides) {
return send(apiOperations.rollDice, { sides: sides });
}

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RpgRoller</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="layout">
<h1>RpgRoller</h1>
<p id="health" class="status">Checking API status...</p>
<form id="roll-form" class="panel">
<label for="sides">Sides</label>
<input id="sides" name="sides" type="number" min="2" max="1000" value="20" required>
<button type="submit">Roll</button>
</form>
<p id="result" class="result">No roll yet.</p>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(165deg, #f2f4f8 0%, #e6ebf5 100%);
color: #1f2937;
}
.layout {
max-width: 32rem;
margin: 0 auto;
padding: 2.5rem 1.25rem;
}
.panel {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
input,
button {
font: inherit;
padding: 0.6rem 0.75rem;
}
button {
border: 0;
border-radius: 0.4rem;
background: #2563eb;
color: #ffffff;
cursor: pointer;
}
.status,
.result {
font-weight: 600;
}

10
TECH.md
View File

@@ -1,5 +1,14 @@
# TECH - Kickoff Blueprint # TECH - Kickoff Blueprint
## 0) Current scaffold status
- Root solution: `RpgRoller.sln`
- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend)
- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
- OpenAPI source: `openapi/RpgRoller.json`
- Generated client target: `RpgRoller/wwwroot/generated/api-client.js`
- Local CI parity entrypoint: `scripts/ci-local.ps1`
## 1) Stack and baseline choices ## 1) Stack and baseline choices
- ASP.NET Core Minimal API on .NET 10. - ASP.NET Core Minimal API on .NET 10.
@@ -170,4 +179,3 @@ Avoid:
- Unbounded in-memory caches. - Unbounded in-memory caches.
- Synchronous external network checks on hot write paths. - Synchronous external network checks on hot write paths.
- Manual API contract duplication between docs/frontend/backend. - Manual API contract duplication between docs/frontend/backend.

109
openapi/RpgRoller.json Normal file
View File

@@ -0,0 +1,109 @@
{
"openapi": "3.0.1",
"info": {
"title": "RpgRoller API",
"version": "1.0.0"
},
"paths": {
"/api/health": {
"get": {
"operationId": "getHealth",
"responses": {
"200": {
"description": "API is reachable.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthResponse"
}
}
}
}
}
}
},
"/api/roll/{sides}": {
"get": {
"operationId": "rollDice",
"parameters": [
{
"name": "sides",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 2,
"maximum": 1000
}
}
],
"responses": {
"200": {
"description": "Roll succeeded.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RollResponse"
}
}
}
},
"400": {
"description": "Validation error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"HealthResponse": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
},
"required": [
"status"
]
},
"RollResponse": {
"type": "object",
"properties": {
"sides": {
"type": "integer",
"format": "int32"
},
"value": {
"type": "integer",
"format": "int32"
}
},
"required": [
"sides",
"value"
]
},
"ApiError": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
},
"required": [
"error"
]
}
}
}
}

12
package-lock.json generated Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "rpgroller",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rpgroller",
"version": "0.1.0"
}
}
}

11
package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "rpgroller",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"generate:api-client": "node ./scripts/generate-api-client.mjs",
"lint": "node ./scripts/lint-frontend.mjs",
"format:check": "node ./scripts/format-check.mjs"
}
}

67
scripts/format-check.mjs Normal file
View File

@@ -0,0 +1,67 @@
import { readdir, readFile } 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 directoriesToScan = [
path.join(repoRoot, "RpgRoller", "wwwroot"),
path.join(repoRoot, "openapi")
];
const filesToScan = [
path.join(repoRoot, "scripts", "generate-api-client.mjs"),
path.join(repoRoot, "scripts", "lint-frontend.mjs"),
path.join(repoRoot, "scripts", "format-check.mjs")
];
async function collectFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const results = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
const children = await collectFiles(fullPath);
results.push(...children);
}
else {
results.push(fullPath);
}
}
return results;
}
const allFiles = [...filesToScan];
for (const directory of directoriesToScan) {
const directoryFiles = await collectFiles(directory);
allFiles.push(...directoryFiles);
}
const failures = [];
for (const filePath of allFiles) {
const text = await readFile(filePath, "utf8");
const relativePath = path.relative(repoRoot, filePath);
const lines = text.split(/\r?\n/);
for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
if (/[ \t]+$/.test(lines[lineNumber])) {
failures.push(`${relativePath}:${lineNumber + 1} has trailing whitespace.`);
}
}
if (text.includes("\t")) {
failures.push(`${relativePath} contains tab characters.`);
}
if (text.length > 0 && !text.endsWith("\n") && !text.endsWith("\r\n")) {
failures.push(`${relativePath} is missing a trailing newline.`);
}
}
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
console.log("Frontend format checks passed.");

View File

@@ -0,0 +1,93 @@
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", "wwwroot", "generated", "api-client.js");
function escapePathSegment(segment) {
return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
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") {
continue;
}
if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
}
operations.push({
operationId: operation.operationId,
method: method.toUpperCase(),
path: pathKey
});
}
}
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 buildClientSource(operations) {
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 sendFunction = `
async function send(operation, pathParams = {}) {
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 = await response.json().catch(() => ({ error: "Unknown API error." }));
throw new Error(errorPayload.error ?? \`Request failed with status \${response.status}\`);
}
return response.json();
}
`.trim();
const exports = operations
.map((operation) => {
const params = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
const signature = params.length === 0 ? "" : params.join(", ");
const pathParams = params.length === 0
? "{}"
: `{ ${params.map((name) => `${name}: ${name}`).join(", ")} }`;
return `export async function ${operation.operationId}(${signature}) {\n return send(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`;
}
const openApiText = await readFile(openApiPath, "utf8");
const document = JSON.parse(openApiText);
const operations = collectOperations(document);
const clientSource = buildClientSource(operations);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, clientSource, "utf8");
console.log(`Generated API client: ${path.relative(repoRoot, outputPath)}`);

42
scripts/lint-frontend.mjs Normal file
View File

@@ -0,0 +1,42 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { spawnSync } from "node:child_process";
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 appJsPath = path.join(repoRoot, "RpgRoller", "wwwroot", "app.js");
const generatedClientPath = path.join(repoRoot, "RpgRoller", "wwwroot", "generated", "api-client.js");
const openApi = JSON.parse(await readFile(openApiPath, "utf8"));
const generatedClient = await readFile(generatedClientPath, "utf8");
const errors = [];
const appSyntaxCheck = spawnSync(process.execPath, ["--check", appJsPath], { encoding: "utf8" });
if (appSyntaxCheck.status !== 0) {
errors.push(`Syntax error in ${path.relative(repoRoot, appJsPath)}:\n${appSyntaxCheck.stderr}`);
}
for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem ?? {})) {
if (operation === null || typeof operation !== "object") {
continue;
}
if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
errors.push(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
continue;
}
if (!generatedClient.includes(`apiOperations.${operation.operationId}`)) {
errors.push(`Generated client is missing operation export for ${operation.operationId}`);
}
}
}
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
console.log("Frontend lint checks passed.");