Scaffold full-stack solution and CI baseline
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal 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
11
FAQ.md
Normal 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
43
README.md
Normal 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
|
||||||
|
```
|
||||||
26
RpgRoller.Tests/RpgRoller.Tests.csproj
Normal file
26
RpgRoller.Tests/RpgRoller.Tests.csproj
Normal 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>
|
||||||
89
RpgRoller.Tests/UnitTest1.cs
Normal file
89
RpgRoller.Tests/UnitTest1.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
RpgRoller.Tests/coverlet.runsettings
Normal file
13
RpgRoller.Tests/coverlet.runsettings
Normal 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
48
RpgRoller.sln
Normal 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
2
RpgRoller.slnx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<Solution>
|
||||||
|
</Solution>
|
||||||
5
RpgRoller/Contracts/ApiContracts.cs
Normal file
5
RpgRoller/Contracts/ApiContracts.cs
Normal 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
31
RpgRoller/Program.cs
Normal 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;
|
||||||
23
RpgRoller/Properties/launchSettings.json
Normal file
23
RpgRoller/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
RpgRoller/RpgRoller.csproj
Normal file
9
RpgRoller/RpgRoller.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
19
RpgRoller/Services/DiceRules.cs
Normal file
19
RpgRoller/Services/DiceRules.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
RpgRoller/Services/IDiceRoller.cs
Normal file
6
RpgRoller/Services/IDiceRoller.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace RpgRoller.Services;
|
||||||
|
|
||||||
|
public interface IDiceRoller
|
||||||
|
{
|
||||||
|
int Roll(int sides);
|
||||||
|
}
|
||||||
9
RpgRoller/Services/RandomDiceRoller.cs
Normal file
9
RpgRoller/Services/RandomDiceRoller.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
RpgRoller/appsettings.json
Normal file
9
RpgRoller/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
31
RpgRoller/wwwroot/app.js
Normal file
31
RpgRoller/wwwroot/app.js
Normal 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();
|
||||||
37
RpgRoller/wwwroot/generated/api-client.js
Normal file
37
RpgRoller/wwwroot/generated/api-client.js
Normal 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 });
|
||||||
|
}
|
||||||
25
RpgRoller/wwwroot/index.html
Normal file
25
RpgRoller/wwwroot/index.html
Normal 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>
|
||||||
42
RpgRoller/wwwroot/styles.css
Normal file
42
RpgRoller/wwwroot/styles.css
Normal 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
10
TECH.md
@@ -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
109
openapi/RpgRoller.json
Normal 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
12
package-lock.json
generated
Normal 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
11
package.json
Normal 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
67
scripts/format-check.mjs
Normal 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.");
|
||||||
93
scripts/generate-api-client.mjs
Normal file
93
scripts/generate-api-client.mjs
Normal 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
42
scripts/lint-frontend.mjs
Normal 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.");
|
||||||
Reference in New Issue
Block a user