diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..646165f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..14cf09d --- /dev/null +++ b/FAQ.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cf043d --- /dev/null +++ b/README.md @@ -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 + ``` diff --git a/RpgRoller.Tests/RpgRoller.Tests.csproj b/RpgRoller.Tests/RpgRoller.Tests.csproj new file mode 100644 index 0000000..9ad4433 --- /dev/null +++ b/RpgRoller.Tests/RpgRoller.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/RpgRoller.Tests/UnitTest1.cs b/RpgRoller.Tests/UnitTest1.cs new file mode 100644 index 0000000..e08cf5a --- /dev/null +++ b/RpgRoller.Tests/UnitTest1.cs @@ -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> +{ + private readonly HttpClient m_Client; + + public UnitTest1(WebApplicationFactory factory) + { + m_Client = factory.WithWebHostBuilder(builder => + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(new FixedDiceRoller(7)); + })).CreateClient(); + } + + [Fact] + public async Task GetHealth_ReturnsOkPayload() + { + var response = await m_Client.GetAsync("/api/health"); + var payload = await response.Content.ReadFromJsonAsync(); + + 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(); + + 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(); + + 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); + } + } +} diff --git a/RpgRoller.Tests/coverlet.runsettings b/RpgRoller.Tests/coverlet.runsettings new file mode 100644 index 0000000..21564f5 --- /dev/null +++ b/RpgRoller.Tests/coverlet.runsettings @@ -0,0 +1,13 @@ + + + + + + + cobertura + GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute + + + + + diff --git a/RpgRoller.sln b/RpgRoller.sln new file mode 100644 index 0000000..ea6d7e9 --- /dev/null +++ b/RpgRoller.sln @@ -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 diff --git a/RpgRoller.slnx b/RpgRoller.slnx new file mode 100644 index 0000000..ba788ff --- /dev/null +++ b/RpgRoller.slnx @@ -0,0 +1,2 @@ + + diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs new file mode 100644 index 0000000..f883b71 --- /dev/null +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -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); diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs new file mode 100644 index 0000000..0b6d1f2 --- /dev/null +++ b/RpgRoller/Program.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using RpgRoller.Contracts; +using RpgRoller.Services; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.MapGet("/api/health", () => TypedResults.Ok(new HealthResponse("ok"))); + +app.MapGet( + "/api/roll/{sides:int}", + Results, BadRequest> (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; diff --git a/RpgRoller/Properties/launchSettings.json b/RpgRoller/Properties/launchSettings.json new file mode 100644 index 0000000..aadb6f8 --- /dev/null +++ b/RpgRoller/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/RpgRoller/RpgRoller.csproj b/RpgRoller/RpgRoller.csproj new file mode 100644 index 0000000..a3a34b6 --- /dev/null +++ b/RpgRoller/RpgRoller.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/RpgRoller/Services/DiceRules.cs b/RpgRoller/Services/DiceRules.cs new file mode 100644 index 0000000..fdb6812 --- /dev/null +++ b/RpgRoller/Services/DiceRules.cs @@ -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; + } +} diff --git a/RpgRoller/Services/IDiceRoller.cs b/RpgRoller/Services/IDiceRoller.cs new file mode 100644 index 0000000..9d9f298 --- /dev/null +++ b/RpgRoller/Services/IDiceRoller.cs @@ -0,0 +1,6 @@ +namespace RpgRoller.Services; + +public interface IDiceRoller +{ + int Roll(int sides); +} diff --git a/RpgRoller/Services/RandomDiceRoller.cs b/RpgRoller/Services/RandomDiceRoller.cs new file mode 100644 index 0000000..e366047 --- /dev/null +++ b/RpgRoller/Services/RandomDiceRoller.cs @@ -0,0 +1,9 @@ +namespace RpgRoller.Services; + +public sealed class RandomDiceRoller : IDiceRoller +{ + public int Roll(int sides) + { + return Random.Shared.Next(1, sides + 1); + } +} diff --git a/RpgRoller/appsettings.json b/RpgRoller/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/RpgRoller/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/RpgRoller/wwwroot/app.js b/RpgRoller/wwwroot/app.js new file mode 100644 index 0000000..cc4a76f --- /dev/null +++ b/RpgRoller/wwwroot/app.js @@ -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(); diff --git a/RpgRoller/wwwroot/generated/api-client.js b/RpgRoller/wwwroot/generated/api-client.js new file mode 100644 index 0000000..69c6ebd --- /dev/null +++ b/RpgRoller/wwwroot/generated/api-client.js @@ -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 }); +} diff --git a/RpgRoller/wwwroot/index.html b/RpgRoller/wwwroot/index.html new file mode 100644 index 0000000..dc6dccb --- /dev/null +++ b/RpgRoller/wwwroot/index.html @@ -0,0 +1,25 @@ + + + + + + RpgRoller + + + +
+

RpgRoller

+

Checking API status...

+ +
+ + + +
+ +

No roll yet.

+
+ + + + diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css new file mode 100644 index 0000000..709db32 --- /dev/null +++ b/RpgRoller/wwwroot/styles.css @@ -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; +} diff --git a/TECH.md b/TECH.md index 3a25986..b132741 100644 --- a/TECH.md +++ b/TECH.md @@ -1,5 +1,14 @@ # 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 - ASP.NET Core Minimal API on .NET 10. @@ -170,4 +179,3 @@ Avoid: - Unbounded in-memory caches. - Synchronous external network checks on hot write paths. - Manual API contract duplication between docs/frontend/backend. - diff --git a/openapi/RpgRoller.json b/openapi/RpgRoller.json new file mode 100644 index 0000000..74d078b --- /dev/null +++ b/openapi/RpgRoller.json @@ -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" + ] + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1efbcaf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "rpgroller", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rpgroller", + "version": "0.1.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..572eef5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/format-check.mjs b/scripts/format-check.mjs new file mode 100644 index 0000000..66e0601 --- /dev/null +++ b/scripts/format-check.mjs @@ -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."); diff --git a/scripts/generate-api-client.mjs b/scripts/generate-api-client.mjs new file mode 100644 index 0000000..086abd0 --- /dev/null +++ b/scripts/generate-api-client.mjs @@ -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)}`); diff --git a/scripts/lint-frontend.mjs b/scripts/lint-frontend.mjs new file mode 100644 index 0000000..d4d0bb5 --- /dev/null +++ b/scripts/lint-frontend.mjs @@ -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.");