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

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;
}