From 1802fd66074c3f6198e0aa5861565222f1c5f93c Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 18 Feb 2026 21:25:07 +0100 Subject: [PATCH] Add OpenAPI contract and generated frontend client --- API.md | 2 + Endpoints/AdminEndpoints.cs | 22 +- Endpoints/AuthEndpoints.cs | 10 +- Endpoints/ResultsEndpoints.cs | 3 +- Endpoints/StateEndpoints.cs | 12 +- Endpoints/SuggestEndpoints.cs | 12 +- Endpoints/VoteEndpoints.cs | 8 +- GameList.Tests/HelperTests.cs | 18 + GameList.Tests/coverlet.runsettings | 13 + GameList.csproj | 7 + Program.cs | 2 + README.md | 5 +- TESTS.md | 2 + openapi/GameList.json | 867 ++++++++++++++++++++++++++++ package.json | 1 + scripts/ci-local.ps1 | 24 +- scripts/generate-api-client.mjs | 209 +++++++ wwwroot/js/api-client.generated.js | 303 ++++++++++ wwwroot/js/api.js | 115 ++-- 19 files changed, 1509 insertions(+), 126 deletions(-) create mode 100644 GameList.Tests/coverlet.runsettings create mode 100644 openapi/GameList.json create mode 100644 scripts/generate-api-client.mjs create mode 100644 wwwroot/js/api-client.generated.js diff --git a/API.md b/API.md index 484daf8..2110c98 100644 --- a/API.md +++ b/API.md @@ -2,6 +2,8 @@ All endpoints are JSON. Most routes require the HttpOnly `player` cookie issued after register/login. Admin access is granted only via an authenticated admin user session (`IsAdmin=true` on the account). Auth and admin-sensitive routes are rate-limited and return HTTP `429` on excessive requests. +The machine-readable source of truth is the generated OpenAPI document at `openapi/GameList.json` (runtime endpoint: `GET /openapi/v1.json`). +Frontend API calls are generated from that document into `wwwroot/js/api-client.generated.js` via `npm run generate:api-client`. ## Auth POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true` only for bootstrap of the first admin account diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index c546e9b..452d15a 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -9,36 +9,36 @@ public static class AdminEndpoints { public static void MapAdminEndpoints(this IEndpointRouteBuilder app) { - var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter(); + var admin = app.MapGroup("/api/admin").WithTags("Admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter(); admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => { var result = await service.SetResultsOpenAsync(request.ResultsOpen); return result.ToHttpResult(Results.Ok); - }); + }).WithName("SetResultsOpen"); admin.MapGet("/vote-status", async (AdminWorkflowService service) => { var result = await service.GetVoteStatusAsync(); return result.ToHttpResult(Results.Ok); - }); + }).WithName("GetVoteStatus"); admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => { var result = await service.GrantJokerAsync(request.PlayerId); return result.ToHttpResult(Results.Ok); - }); + }).WithName("GrantJoker"); admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => { var result = await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase); return result.ToHttpResult(Results.Ok); - }); + }).WithName("SetPlayerPhase"); admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) => { var result = await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin); return result.ToHttpResult(Results.Ok); - }); + }).WithName("SetPlayerAdmin"); admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => { @@ -48,7 +48,7 @@ public static class AdminEndpoints var result = await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx); return result.ToHttpResult(Results.Ok); - }); + }).WithName("DeletePlayer"); admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => { @@ -58,7 +58,7 @@ public static class AdminEndpoints var result = await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId); return result.ToHttpResult(Results.Ok); - }); + }).WithName("LinkSuggestions"); admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => { @@ -68,7 +68,7 @@ public static class AdminEndpoints var result = await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId); return result.ToHttpResult(Results.Ok); - }); + }).WithName("UnlinkSuggestions"); admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => { @@ -78,7 +78,7 @@ public static class AdminEndpoints var result = await service.ResetAsync(player.Id, request.Password, ctx); return result.ToHttpResult(Results.Ok); - }); + }).WithName("Reset"); admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => { @@ -88,7 +88,7 @@ public static class AdminEndpoints var result = await service.FactoryResetAsync(player.Id, request.Password, ctx); return result.ToHttpResult(Results.Ok); - }); + }).WithName("FactoryReset"); } } diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index 83abf5a..ba208fe 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -11,13 +11,13 @@ public static class AuthEndpoints { public static void MapAuthEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/auth").RequireRateLimiting("auth-sensitive"); + var group = app.MapGroup("/api/auth").WithTags("Auth").RequireRateLimiting("auth-sensitive"); group.MapGet("/options", async (AppDbContext db) => { var ownerExists = await db.Players.AsNoTracking().AnyAsync(p => p.IsOwner); return Results.Ok(new AuthOptionsResponse(ownerExists)); - }); + }).WithName("GetAuthOptions"); group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, AuthAttemptMonitor authAttemptMonitor) => { @@ -94,7 +94,7 @@ public static class AuthEndpoints player.DisplayName, player.IsAdmin )); - }); + }).WithName("Register"); group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db, AuthAttemptMonitor authAttemptMonitor) => { @@ -137,13 +137,13 @@ public static class AuthEndpoints player.DisplayName, player.IsAdmin )); - }); + }).WithName("Login"); group.MapPost("/logout", async (HttpContext ctx) => { await PlayerIdentityExtensions.SignOutPlayerAsync(ctx); return Results.NoContent(); - }); + }).WithName("Logout"); } private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim(); diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index 3863d6a..cbfb8ac 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -9,6 +9,7 @@ public static class ResultsEndpoints public static void MapResultsEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/results") + .WithTags("Results") .RequireAuthorization() .AddEndpointFilter(new PhaseRequirementFilter(Phase.Results)); @@ -20,7 +21,7 @@ public static class ResultsEndpoints var result = await service.GetResultsAsync(player.Id); return result.ToHttpResult(Results.Ok); - }); + }).WithName("GetResults"); } } diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 6b7f451..9e997c2 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -7,7 +7,7 @@ public static class StateEndpoints { public static void MapStateEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api").RequireAuthorization(); + var group = app.MapGroup("/api").WithTags("State").RequireAuthorization(); group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service, StateChangeNotifier notifier) => { @@ -28,7 +28,7 @@ public static class StateEndpoints ctx.Response.Headers.ETag = notifier.CurrentEtag; return Results.Ok(payload); }); - }); + }).WithName("GetState"); group.MapGet("/events/state", async (HttpContext ctx, AppDbContext db, StateChangeNotifier notifier) => { @@ -73,7 +73,7 @@ public static class StateEndpoints } return Results.Empty; - }); + }).WithName("GetStateEvents"); group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { @@ -83,7 +83,7 @@ public static class StateEndpoints var result = await service.GetMeAsync(player); return result.ToHttpResult(Results.Ok); - }); + }).WithName("GetMe"); group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { @@ -93,7 +93,7 @@ public static class StateEndpoints var result = await service.NextPhaseAsync(player); return result.ToHttpResult(Results.Ok); - }); + }).WithName("NextPhase"); group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { @@ -103,7 +103,7 @@ public static class StateEndpoints var result = await service.PrevPhaseAsync(player); return result.ToHttpResult(Results.Ok); - }); + }).WithName("PrevPhase"); } diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 98e9219..2d7738d 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -9,7 +9,7 @@ public static class SuggestEndpoints { public static void MapSuggestEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/suggestions").RequireAuthorization(); + var group = app.MapGroup("/api/suggestions").WithTags("Suggestions").RequireAuthorization(); group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { @@ -19,7 +19,7 @@ public static class SuggestEndpoints var result = await service.GetMineAsync(player.Id); return result.ToHttpResult(Results.Ok); - }); + }).WithName("GetMySuggestions"); group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { @@ -42,7 +42,7 @@ public static class SuggestEndpoints ); return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload)); - }).AddEndpointFilter(new PhaseOrJokerFilter()); + }).AddEndpointFilter(new PhaseOrJokerFilter()).WithName("CreateSuggestion"); group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { @@ -52,7 +52,7 @@ public static class SuggestEndpoints var result = await service.DeleteAsync(player.Id, id); return result.ToHttpResult(Results.NoContent); - }); + }).WithName("DeleteSuggestion"); group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { @@ -76,7 +76,7 @@ public static class SuggestEndpoints ); return result.ToHttpResult(Results.Ok); - }); + }).WithName("UpdateSuggestion"); group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => { @@ -86,7 +86,7 @@ public static class SuggestEndpoints var result = await service.GetAllAsync(player.Id); return result.ToHttpResult(Results.Ok); - }); + }).WithName("GetAllSuggestions"); } } diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index 54df5a0..9b1db74 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -9,7 +9,7 @@ public static class VoteEndpoints { public static void MapVoteEndpoints(this IEndpointRouteBuilder app) { - var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote)); + var group = app.MapGroup("/api/votes").WithTags("Votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote)); group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, VoteWorkflowService service) => { @@ -19,7 +19,7 @@ public static class VoteEndpoints var result = await service.GetMineAsync(player.Id); return result.ToHttpResult(Results.Ok); - }); + }).WithName("GetMyVotes"); group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => { @@ -29,7 +29,7 @@ public static class VoteEndpoints var result = await service.UpsertAsync(player.Id, request.SuggestionId, request.Score); return result.ToHttpResult(Results.Ok); - }); + }).WithName("UpsertVote"); group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => { @@ -39,7 +39,7 @@ public static class VoteEndpoints var result = await service.SetFinalizeAsync(player.Id, request.Final); return result.ToHttpResult(Results.Ok); - }); + }).WithName("SetVotesFinalized"); } } diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 02c9011..63f73f4 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -40,6 +40,24 @@ public class HelperTests Assert.False(hasRewriteMethod); } + [Fact] + public async Task OpenApi_document_exposes_stable_operation_ids() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/openapi/v1.json"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + var paths = json.GetProperty("paths"); + + Assert.Equal("Login", paths.GetProperty("/api/auth/login").GetProperty("post").GetProperty("operationId").GetString()); + Assert.Equal("GetState", paths.GetProperty("/api/state").GetProperty("get").GetProperty("operationId").GetString()); + Assert.Equal("CreateSuggestion", paths.GetProperty("/api/suggestions").GetProperty("post").GetProperty("operationId").GetString()); + Assert.Equal("DeletePlayer", paths.GetProperty("/api/admin/players/{playerId}").GetProperty("delete").GetProperty("operationId").GetString()); + } + [Fact] public async Task IsReachableImageAsync_rejects_redirect_and_accepts_image() { diff --git a/GameList.Tests/coverlet.runsettings b/GameList.Tests/coverlet.runsettings new file mode 100644 index 0000000..7ae1acd --- /dev/null +++ b/GameList.Tests/coverlet.runsettings @@ -0,0 +1,13 @@ + + + + + + + cobertura + **/obj/**/Microsoft.AspNetCore.OpenApi.SourceGenerators/**/*.cs + + + + + diff --git a/GameList.csproj b/GameList.csproj index f246219..c6c6aa2 100644 --- a/GameList.csproj +++ b/GameList.csproj @@ -4,15 +4,22 @@ net10.0 enable enable + true + $(MSBuildProjectDirectory)\openapi + runtime; build; native; contentfiles; analyzers; buildtransitive all + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Program.cs b/Program.cs index 94d6bc9..d17a676 100644 --- a/Program.cs +++ b/Program.cs @@ -47,6 +47,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddOpenApi("v1"); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); @@ -160,6 +161,7 @@ app.UseDefaultFiles(); app.UseStaticFiles(); app.MapHealthChecks(); +app.MapOpenApi("/openapi/{documentName}.json"); app.MapAuthEndpoints(); app.MapStateEndpoints(); app.MapSuggestEndpoints(); diff --git a/README.md b/README.md index e946835..0fbf836 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS ## Frontend Tooling - Install tooling: `npm install` +- Generate API client from OpenAPI: `npm run generate:api-client` (expects `openapi/GameList.json` generated by `dotnet build`) - Lint JS: `npm run lint` - Check formatting: `npm run format:check` - Apply formatting: `npm run format` @@ -59,6 +60,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS ## Operations - API surface and endpoint contract: `API.md` +- Generated OpenAPI document: `openapi/GameList.json` (runtime: `/openapi/v1.json`) - Product/feature expectations: `SPEC.md` - IIS deployment notes: `IIS.md` - Test strategy details: `TESTS.md` @@ -68,7 +70,8 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS GitHub Actions workflow: `.github/workflows/ci.yml` - Restores dependencies -- Runs frontend lint and format checks - Builds with warnings treated as errors +- Generates frontend API client from OpenAPI contract +- Runs frontend lint and format checks - Runs `GameList.Tests` with coverage collection - Enforces minimum coverage thresholds (line 90%, branch 70%) diff --git a/TESTS.md b/TESTS.md index 478ee0d..d5e8871 100644 --- a/TESTS.md +++ b/TESTS.md @@ -92,6 +92,7 @@ stateDiagram-v2 - IsReachableImageAsync: with mocked Http responses covers head success, get fallback, redirect rejection, size guard, and private/reserved host range detection (IPv4/IPv6). - BuildLinkRoots/LinkedIdsFor/FindRootId: cover disjoint groups, chains, cycles guard (visited set), non-existent ids. - Program startup avoids runtime frontend file rewrites; BasePath remains purely configuration/deploy managed. +- OpenAPI endpoint exposes generated contract with stable operationIds used by frontend client generation (`/openapi/v1.json`). - Global exception handler returns 500 with JSON body and logs error. - /health returns {status:"ok"}. - Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes. @@ -100,6 +101,7 @@ stateDiagram-v2 ## Coverage Policy - CI and local script enforce Cobertura thresholds from test coverage collection. +- Coverage collection excludes OpenAPI source-generator files under `obj/**/Microsoft.AspNetCore.OpenApi.SourceGenerators/**` to avoid penalizing generated framework code. - Minimum line coverage: 90%. - Minimum branch coverage: 70%. diff --git a/openapi/GameList.json b/openapi/GameList.json new file mode 100644 index 0000000..560bced --- /dev/null +++ b/openapi/GameList.json @@ -0,0 +1,867 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "GameList | v1", + "version": "1.0.0" + }, + "paths": { + "/health": { + "get": { + "tags": [ + "GameList" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/auth/options": { + "get": { + "tags": [ + "Auth" + ], + "operationId": "GetAuthOptions", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/auth/register": { + "post": { + "tags": [ + "Auth" + ], + "operationId": "Register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "operationId": "Login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/auth/logout": { + "post": { + "tags": [ + "Auth" + ], + "operationId": "Logout", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/state": { + "get": { + "tags": [ + "State" + ], + "operationId": "GetState", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/events/state": { + "get": { + "tags": [ + "State" + ], + "operationId": "GetStateEvents", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/me": { + "get": { + "tags": [ + "State" + ], + "operationId": "GetMe", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/me/phase/next": { + "post": { + "tags": [ + "State" + ], + "operationId": "NextPhase", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/me/phase/prev": { + "post": { + "tags": [ + "State" + ], + "operationId": "PrevPhase", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/suggestions/mine": { + "get": { + "tags": [ + "Suggestions" + ], + "operationId": "GetMySuggestions", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/suggestions": { + "post": { + "tags": [ + "Suggestions" + ], + "operationId": "CreateSuggestion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuggestionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/suggestions/{id}": { + "delete": { + "tags": [ + "Suggestions" + ], + "operationId": "DeleteSuggestion", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "tags": [ + "Suggestions" + ], + "operationId": "UpdateSuggestion", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuggestionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/suggestions/all": { + "get": { + "tags": [ + "Suggestions" + ], + "operationId": "GetAllSuggestions", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/votes/mine": { + "get": { + "tags": [ + "Votes" + ], + "operationId": "GetMyVotes", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/votes": { + "post": { + "tags": [ + "Votes" + ], + "operationId": "UpsertVote", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/votes/finalize": { + "post": { + "tags": [ + "Votes" + ], + "operationId": "SetVotesFinalized", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteFinalizeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/results": { + "get": { + "tags": [ + "Results" + ], + "operationId": "GetResults", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/results": { + "post": { + "tags": [ + "Admin" + ], + "operationId": "SetResultsOpen", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResultsOpenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/vote-status": { + "get": { + "tags": [ + "Admin" + ], + "operationId": "GetVoteStatus", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/joker": { + "post": { + "tags": [ + "Admin" + ], + "operationId": "GrantJoker", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantJokerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/player-phase": { + "post": { + "tags": [ + "Admin" + ], + "operationId": "SetPlayerPhase", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPlayerPhaseRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/player-admin": { + "post": { + "tags": [ + "Admin" + ], + "operationId": "SetPlayerAdmin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPlayerAdminRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/players/{playerId}": { + "delete": { + "tags": [ + "Admin" + ], + "operationId": "DeletePlayer", + "parameters": [ + { + "name": "playerId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/link-suggestions": { + "post": { + "tags": [ + "Admin" + ], + "operationId": "LinkSuggestions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkSuggestionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/unlink-suggestions": { + "post": { + "tags": [ + "Admin" + ], + "operationId": "UnlinkSuggestions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnlinkSuggestionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/reset": { + "post": { + "tags": [ + "Admin" + ], + "operationId": "Reset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/admin/factory-reset": { + "post": { + "tags": [ + "Admin" + ], + "operationId": "FactoryReset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "components": { + "schemas": { + "AdminPasswordRequest": { + "required": [ + "password" + ], + "type": "object", + "properties": { + "password": { + "type": "string" + } + } + }, + "GrantJokerRequest": { + "required": [ + "playerId" + ], + "type": "object", + "properties": { + "playerId": { + "type": "string", + "format": "uuid" + } + } + }, + "LinkSuggestionsRequest": { + "required": [ + "sourceSuggestionId", + "targetSuggestionId" + ], + "type": "object", + "properties": { + "sourceSuggestionId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "targetSuggestionId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "LoginRequest": { + "required": [ + "username", + "password" + ], + "type": "object", + "properties": { + "username": { + "type": [ + "null", + "string" + ] + }, + "password": { + "type": [ + "null", + "string" + ] + } + } + }, + "Phase": { + "enum": [ + "Suggest", + "Vote", + "Results" + ] + }, + "RegisterRequest": { + "required": [ + "username", + "password", + "displayName", + "adminKey" + ], + "type": "object", + "properties": { + "username": { + "type": [ + "null", + "string" + ] + }, + "password": { + "type": [ + "null", + "string" + ] + }, + "displayName": { + "type": [ + "null", + "string" + ] + }, + "adminKey": { + "type": [ + "null", + "string" + ] + } + } + }, + "ResultsOpenRequest": { + "required": [ + "resultsOpen" + ], + "type": "object", + "properties": { + "resultsOpen": { + "type": "boolean" + } + } + }, + "SetPlayerAdminRequest": { + "required": [ + "playerId", + "isAdmin" + ], + "type": "object", + "properties": { + "playerId": { + "type": "string", + "format": "uuid" + }, + "isAdmin": { + "type": "boolean" + } + } + }, + "SetPlayerPhaseRequest": { + "required": [ + "playerId", + "phase" + ], + "type": "object", + "properties": { + "playerId": { + "type": "string", + "format": "uuid" + }, + "phase": { + "$ref": "#/components/schemas/Phase" + } + } + }, + "SuggestionRequest": { + "required": [ + "name", + "genre", + "description", + "screenshotUrl", + "youtubeUrl", + "gameUrl", + "minPlayers", + "maxPlayers" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "genre": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "screenshotUrl": { + "type": [ + "null", + "string" + ] + }, + "youtubeUrl": { + "type": [ + "null", + "string" + ] + }, + "gameUrl": { + "type": [ + "null", + "string" + ] + }, + "minPlayers": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" + }, + "maxPlayers": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" + } + } + }, + "UnlinkSuggestionsRequest": { + "required": [ + "suggestionId" + ], + "type": "object", + "properties": { + "suggestionId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "VoteFinalizeRequest": { + "required": [ + "final" + ], + "type": "object", + "properties": { + "final": { + "type": "boolean" + } + } + }, + "VoteRequest": { + "required": [ + "suggestionId", + "score" + ], + "type": "object", + "properties": { + "suggestionId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "score": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + } + } + }, + "tags": [ + { + "name": "GameList" + }, + { + "name": "Auth" + }, + { + "name": "State" + }, + { + "name": "Suggestions" + }, + { + "name": "Votes" + }, + { + "name": "Results" + }, + { + "name": "Admin" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 1eb02fc..3f82a09 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "generate:api-client": "node ./scripts/generate-api-client.mjs", "lint": "eslint \"wwwroot/**/*.js\"", "format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"", "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\"" diff --git a/scripts/ci-local.ps1 b/scripts/ci-local.ps1 index 5d4a90b..dcb4648 100644 --- a/scripts/ci-local.ps1 +++ b/scripts/ci-local.ps1 @@ -31,14 +31,6 @@ try { } } - Invoke-Step -Name "Lint frontend" -Action { - npm run lint - } - - Invoke-Step -Name "Check frontend formatting" -Action { - npm run format:check - } - if (-not $SkipDotnetRestore) { Invoke-Step -Name "Restore .NET solution" -Action { dotnet restore GameList.sln @@ -51,12 +43,24 @@ try { } } + Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action { + npm run generate:api-client + } + + Invoke-Step -Name "Lint frontend" -Action { + npm run lint + } + + Invoke-Step -Name "Check frontend formatting" -Action { + npm run format:check + } + Invoke-Step -Name "Run tests" -Action { if ($SkipBuild) { - dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" + dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings } else { - dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" + dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings } } diff --git a/scripts/generate-api-client.mjs b/scripts/generate-api-client.mjs new file mode 100644 index 0000000..e396695 --- /dev/null +++ b/scripts/generate-api-client.mjs @@ -0,0 +1,209 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import prettier from "prettier"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const openApiPath = path.join(repoRoot, "openapi", "GameList.json"); +const outputPath = path.join(repoRoot, "wwwroot", "js", "api-client.generated.js"); + +const requiredOperationIds = [ + "GetAuthOptions", + "Register", + "Login", + "Logout", + "GetState", + "GetStateEvents", + "GetMe", + "NextPhase", + "PrevPhase", + "GetMySuggestions", + "CreateSuggestion", + "DeleteSuggestion", + "UpdateSuggestion", + "GetAllSuggestions", + "GetMyVotes", + "UpsertVote", + "SetVotesFinalized", + "GetResults", + "SetResultsOpen", + "GetVoteStatus", + "GrantJoker", + "SetPlayerPhase", + "SetPlayerAdmin", + "DeletePlayer", + "LinkSuggestions", + "UnlinkSuggestions", + "Reset", + "FactoryReset", +]; + +if (!fs.existsSync(openApiPath)) { + throw new Error(`OpenAPI document not found at ${openApiPath}. Build the .NET solution first.`); +} + +const document = JSON.parse(fs.readFileSync(openApiPath, "utf8")); +const operations = collectOperations(document); +validateRequiredOperations(operations); + +const generated = renderClient(operations); +const prettierConfig = + (await prettier.resolveConfig(outputPath, { editorconfig: true })) ?? {}; +const formatted = await prettier.format(generated, { + ...prettierConfig, + filepath: outputPath, +}); +fs.writeFileSync(outputPath, formatted, "utf8"); +console.log(`Generated ${path.relative(repoRoot, outputPath)} from ${path.relative(repoRoot, openApiPath)}`); + +function collectOperations(openApiDocument) { + const methods = ["get", "post", "put", "delete", "patch"]; + const entries = []; + + for (const [routePath, pathItem] of Object.entries(openApiDocument.paths ?? {})) { + for (const method of methods) { + const operation = pathItem?.[method]; + if (!operation?.operationId) continue; + if (!routePath.startsWith("/api/")) continue; + + const pathParameters = (operation.parameters ?? []) + .filter((p) => p.in === "path") + .map((p) => p.name); + + entries.push({ + operationId: operation.operationId, + method: method.toUpperCase(), + path: routePath, + hasBody: Boolean(operation.requestBody), + pathParameters, + }); + } + } + + entries.sort((a, b) => a.operationId.localeCompare(b.operationId)); + return entries; +} + +function validateRequiredOperations(operationsList) { + const found = new Set(operationsList.map((operation) => operation.operationId)); + const missing = requiredOperationIds.filter((operationId) => !found.has(operationId)); + if (missing.length > 0) { + throw new Error(`OpenAPI document is missing expected operations: ${missing.join(", ")}`); + } +} + +function renderClient(operationsList) { + const operationObjectLiteral = operationsList + .map((operation) => { + const pathParams = `[${operation.pathParameters.map((name) => `"${name}"`).join(", ")}]`; + return [ + ` ${operation.operationId}: {`, + ` method: "${operation.method}",`, + ` path: "${operation.path}",`, + ` hasBody: ${operation.hasBody ? "true" : "false"},`, + ` pathParameters: ${pathParams},`, + " },", + ].join("\n"); + }) + .join("\n"); + + const clientFunctions = requiredOperationIds + .map((operationId) => { + const methodName = toCamelCase(operationId); + return ` ${methodName}: (options = {}) => requestOperation("${operationId}", options),`; + }) + .join("\n"); + + return `// AUTO-GENERATED FILE. DO NOT EDIT. +// Source: scripts/generate-api-client.mjs and openapi/GameList.json + +const defaultHeaders = { "Content-Type": "application/json" }; + +const rawBase = document.querySelector('meta[name="app-base"]')?.content || ""; +const basePath = normalizeBase(rawBase); +const withBase = (routePath) => \`\${basePath}\${routePath}\`; + +function normalizeBase(value) { + if (!value) return ""; + if (!value.startsWith("/")) return \`/\${value}\`; + return value.endsWith("/") ? value.slice(0, -1) : value; +} + +function toApiError(res, fallbackMessage = \`\${res.status}\`) { + const err = new Error(fallbackMessage); + err.status = res.status; + return err; +} + +function buildPath(template, pathParameters = {}) { + return template.replace(/{([^}]+)}/g, (_, key) => { + const value = pathParameters[key]; + if (value === undefined || value === null) { + throw new Error(\`Missing path parameter "\${key}" for route \${template}\`); + } + + return encodeURIComponent(String(value)); + }); +} + +async function parseApiError(res) { + try { + const data = await res.json(); + const message = data.error || data.detail || data.title || JSON.stringify(data); + return toApiError(res, message); + } catch { + return toApiError(res); + } +} + +export const operations = Object.freeze({ +${operationObjectLiteral} +}); + +export function resolveOperationPath(operationId, pathParameters = {}) { + const operation = operations[operationId]; + if (!operation) { + throw new Error(\`Unknown operationId "\${operationId}"\`); + } + + return withBase(buildPath(operation.path, pathParameters)); +} + +export async function requestOperation( + operationId, + { pathParameters = {}, body, headers = {}, raw = false, acceptStatuses = [] } = {} +) { + const operation = operations[operationId]; + if (!operation) { + throw new Error(\`Unknown operationId "\${operationId}"\`); + } + + const response = await fetch(resolveOperationPath(operationId, pathParameters), { + method: operation.method, + credentials: "same-origin", + headers: { ...defaultHeaders, ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const acceptedStatusSet = new Set(acceptStatuses); + if (!response.ok && !acceptedStatusSet.has(response.status)) { + throw await parseApiError(response); + } + + if (raw) return response; + if (response.status === 204) return null; + return response.json(); +} + +export const apiClient = Object.freeze({ +${clientFunctions} +}); +`; +} + +function toCamelCase(value) { + if (!value) return value; + return `${value.charAt(0).toLowerCase()}${value.slice(1)}`; +} diff --git a/wwwroot/js/api-client.generated.js b/wwwroot/js/api-client.generated.js new file mode 100644 index 0000000..28acda9 --- /dev/null +++ b/wwwroot/js/api-client.generated.js @@ -0,0 +1,303 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// Source: scripts/generate-api-client.mjs and openapi/GameList.json + +const defaultHeaders = { "Content-Type": "application/json" }; + +const rawBase = document.querySelector('meta[name="app-base"]')?.content || ""; +const basePath = normalizeBase(rawBase); +const withBase = (routePath) => `${basePath}${routePath}`; + +function normalizeBase(value) { + if (!value) return ""; + if (!value.startsWith("/")) return `/${value}`; + return value.endsWith("/") ? value.slice(0, -1) : value; +} + +function toApiError(res, fallbackMessage = `${res.status}`) { + const err = new Error(fallbackMessage); + err.status = res.status; + return err; +} + +function buildPath(template, pathParameters = {}) { + return template.replace(/{([^}]+)}/g, (_, key) => { + const value = pathParameters[key]; + if (value === undefined || value === null) { + throw new Error( + `Missing path parameter "${key}" for route ${template}`, + ); + } + + return encodeURIComponent(String(value)); + }); +} + +async function parseApiError(res) { + try { + const data = await res.json(); + const message = + data.error || data.detail || data.title || JSON.stringify(data); + return toApiError(res, message); + } catch { + return toApiError(res); + } +} + +export const operations = Object.freeze({ + CreateSuggestion: { + method: "POST", + path: "/api/suggestions", + hasBody: true, + pathParameters: [], + }, + DeletePlayer: { + method: "DELETE", + path: "/api/admin/players/{playerId}", + hasBody: true, + pathParameters: ["playerId"], + }, + DeleteSuggestion: { + method: "DELETE", + path: "/api/suggestions/{id}", + hasBody: false, + pathParameters: ["id"], + }, + FactoryReset: { + method: "POST", + path: "/api/admin/factory-reset", + hasBody: true, + pathParameters: [], + }, + GetAllSuggestions: { + method: "GET", + path: "/api/suggestions/all", + hasBody: false, + pathParameters: [], + }, + GetAuthOptions: { + method: "GET", + path: "/api/auth/options", + hasBody: false, + pathParameters: [], + }, + GetMe: { + method: "GET", + path: "/api/me", + hasBody: false, + pathParameters: [], + }, + GetMySuggestions: { + method: "GET", + path: "/api/suggestions/mine", + hasBody: false, + pathParameters: [], + }, + GetMyVotes: { + method: "GET", + path: "/api/votes/mine", + hasBody: false, + pathParameters: [], + }, + GetResults: { + method: "GET", + path: "/api/results", + hasBody: false, + pathParameters: [], + }, + GetState: { + method: "GET", + path: "/api/state", + hasBody: false, + pathParameters: [], + }, + GetStateEvents: { + method: "GET", + path: "/api/events/state", + hasBody: false, + pathParameters: [], + }, + GetVoteStatus: { + method: "GET", + path: "/api/admin/vote-status", + hasBody: false, + pathParameters: [], + }, + GrantJoker: { + method: "POST", + path: "/api/admin/joker", + hasBody: true, + pathParameters: [], + }, + LinkSuggestions: { + method: "POST", + path: "/api/admin/link-suggestions", + hasBody: true, + pathParameters: [], + }, + Login: { + method: "POST", + path: "/api/auth/login", + hasBody: true, + pathParameters: [], + }, + Logout: { + method: "POST", + path: "/api/auth/logout", + hasBody: false, + pathParameters: [], + }, + NextPhase: { + method: "POST", + path: "/api/me/phase/next", + hasBody: false, + pathParameters: [], + }, + PrevPhase: { + method: "POST", + path: "/api/me/phase/prev", + hasBody: false, + pathParameters: [], + }, + Register: { + method: "POST", + path: "/api/auth/register", + hasBody: true, + pathParameters: [], + }, + Reset: { + method: "POST", + path: "/api/admin/reset", + hasBody: true, + pathParameters: [], + }, + SetPlayerAdmin: { + method: "POST", + path: "/api/admin/player-admin", + hasBody: true, + pathParameters: [], + }, + SetPlayerPhase: { + method: "POST", + path: "/api/admin/player-phase", + hasBody: true, + pathParameters: [], + }, + SetResultsOpen: { + method: "POST", + path: "/api/admin/results", + hasBody: true, + pathParameters: [], + }, + SetVotesFinalized: { + method: "POST", + path: "/api/votes/finalize", + hasBody: true, + pathParameters: [], + }, + UnlinkSuggestions: { + method: "POST", + path: "/api/admin/unlink-suggestions", + hasBody: true, + pathParameters: [], + }, + UpdateSuggestion: { + method: "PUT", + path: "/api/suggestions/{id}", + hasBody: true, + pathParameters: ["id"], + }, + UpsertVote: { + method: "POST", + path: "/api/votes", + hasBody: true, + pathParameters: [], + }, +}); + +export function resolveOperationPath(operationId, pathParameters = {}) { + const operation = operations[operationId]; + if (!operation) { + throw new Error(`Unknown operationId "${operationId}"`); + } + + return withBase(buildPath(operation.path, pathParameters)); +} + +export async function requestOperation( + operationId, + { + pathParameters = {}, + body, + headers = {}, + raw = false, + acceptStatuses = [], + } = {}, +) { + const operation = operations[operationId]; + if (!operation) { + throw new Error(`Unknown operationId "${operationId}"`); + } + + const response = await fetch( + resolveOperationPath(operationId, pathParameters), + { + method: operation.method, + credentials: "same-origin", + headers: { ...defaultHeaders, ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }, + ); + + const acceptedStatusSet = new Set(acceptStatuses); + if (!response.ok && !acceptedStatusSet.has(response.status)) { + throw await parseApiError(response); + } + + if (raw) return response; + if (response.status === 204) return null; + return response.json(); +} + +export const apiClient = Object.freeze({ + getAuthOptions: (options = {}) => + requestOperation("GetAuthOptions", options), + register: (options = {}) => requestOperation("Register", options), + login: (options = {}) => requestOperation("Login", options), + logout: (options = {}) => requestOperation("Logout", options), + getState: (options = {}) => requestOperation("GetState", options), + getStateEvents: (options = {}) => + requestOperation("GetStateEvents", options), + getMe: (options = {}) => requestOperation("GetMe", options), + nextPhase: (options = {}) => requestOperation("NextPhase", options), + prevPhase: (options = {}) => requestOperation("PrevPhase", options), + getMySuggestions: (options = {}) => + requestOperation("GetMySuggestions", options), + createSuggestion: (options = {}) => + requestOperation("CreateSuggestion", options), + deleteSuggestion: (options = {}) => + requestOperation("DeleteSuggestion", options), + updateSuggestion: (options = {}) => + requestOperation("UpdateSuggestion", options), + getAllSuggestions: (options = {}) => + requestOperation("GetAllSuggestions", options), + getMyVotes: (options = {}) => requestOperation("GetMyVotes", options), + upsertVote: (options = {}) => requestOperation("UpsertVote", options), + setVotesFinalized: (options = {}) => + requestOperation("SetVotesFinalized", options), + getResults: (options = {}) => requestOperation("GetResults", options), + setResultsOpen: (options = {}) => + requestOperation("SetResultsOpen", options), + getVoteStatus: (options = {}) => requestOperation("GetVoteStatus", options), + grantJoker: (options = {}) => requestOperation("GrantJoker", options), + setPlayerPhase: (options = {}) => + requestOperation("SetPlayerPhase", options), + setPlayerAdmin: (options = {}) => + requestOperation("SetPlayerAdmin", options), + deletePlayer: (options = {}) => requestOperation("DeletePlayer", options), + linkSuggestions: (options = {}) => + requestOperation("LinkSuggestions", options), + unlinkSuggestions: (options = {}) => + requestOperation("UnlinkSuggestions", options), + reset: (options = {}) => requestOperation("Reset", options), + factoryReset: (options = {}) => requestOperation("FactoryReset", options), +}); diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index ac5f3b1..71200a0 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -1,35 +1,13 @@ -const defaultHeaders = { "Content-Type": "application/json" }; - -const rawBase = document.querySelector('meta[name="app-base"]')?.content || ""; -const basePath = normalizeBase(rawBase); -const withBase = (path) => `${basePath}${path}`; - -function normalizeBase(value) { - if (!value) return ""; - if (!value.startsWith("/")) return `/${value}`; - return value.endsWith("/") ? value.slice(0, -1) : value; -} - -async function request(path, { method = "GET", body } = {}) { - const res = await fetch(withBase(path), { - method, - credentials: "same-origin", - headers: defaultHeaders, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!res.ok) throw await toApiError(res); - return res.status === 204 ? null : res.json(); -} +import { apiClient, resolveOperationPath } from "./api-client.generated.js"; async function requestState(ifNoneMatch) { - const headers = { ...defaultHeaders }; + const headers = {}; if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch; - const res = await fetch(withBase("/api/state"), { - method: "GET", - credentials: "same-origin", + const res = await apiClient.getState({ headers, + raw: true, + acceptStatuses: [304], }); if (res.status === 304) { @@ -40,8 +18,6 @@ async function requestState(ifNoneMatch) { }; } - if (!res.ok) throw await toApiError(res); - return { notModified: false, etag: res.headers.get("ETag"), @@ -49,92 +25,67 @@ async function requestState(ifNoneMatch) { }; } -async function toApiError(res) { - let msg = `${res.status}`; - try { - const data = await res.json(); - msg = data.error || data.detail || data.title || JSON.stringify(data); - } catch { - /* ignore */ - } - const err = new Error(msg); - err.status = res.status; - return err; -} - export const api = { state: (ifNoneMatch) => requestState(ifNoneMatch), - stateEventsUrl: () => withBase("/api/events/state"), - me: () => request("/api/me"), - authOptions: () => request("/api/auth/options"), - register: (payload) => - request("/api/auth/register", { method: "POST", body: payload }), - login: (payload) => - request("/api/auth/login", { method: "POST", body: payload }), - logout: () => request("/api/auth/logout", { method: "POST" }), + stateEventsUrl: () => resolveOperationPath("GetStateEvents"), + me: () => apiClient.getMe(), + authOptions: () => apiClient.getAuthOptions(), + register: (payload) => apiClient.register({ body: payload }), + login: (payload) => apiClient.login({ body: payload }), + logout: () => apiClient.logout(), - mySuggestions: () => request("/api/suggestions/mine"), + mySuggestions: () => apiClient.getMySuggestions(), createSuggestion: (payload) => - request("/api/suggestions", { method: "POST", body: payload }), + apiClient.createSuggestion({ body: payload }), deleteSuggestion: (id) => - request(`/api/suggestions/${id}`, { method: "DELETE" }), + apiClient.deleteSuggestion({ pathParameters: { id } }), updateSuggestion: (id, payload) => - request(`/api/suggestions/${id}`, { method: "PUT", body: payload }), - allSuggestions: () => request("/api/suggestions/all"), + apiClient.updateSuggestion({ pathParameters: { id }, body: payload }), + allSuggestions: () => apiClient.getAllSuggestions(), - myVotes: () => request("/api/votes/mine"), + myVotes: () => apiClient.getMyVotes(), vote: (suggestionId, score) => - request("/api/votes", { - method: "POST", + apiClient.upsertVote({ body: { suggestionId, score }, }), - finalizeVotes: (final) => - request("/api/votes/finalize", { method: "POST", body: { final } }), + finalizeVotes: (final) => apiClient.setVotesFinalized({ body: { final } }), - results: () => request("/api/results"), - nextPhase: () => request("/api/me/phase/next", { method: "POST" }), - prevPhase: () => request("/api/me/phase/prev", { method: "POST" }), + results: () => apiClient.getResults(), + nextPhase: () => apiClient.nextPhase(), + prevPhase: () => apiClient.prevPhase(), }; export const adminApi = { setResultsOpen: (resultsOpen) => - request("/api/admin/results", { - method: "POST", + apiClient.setResultsOpen({ body: { resultsOpen }, }), - voteStatus: () => request("/api/admin/vote-status"), - reset: (password) => - request("/api/admin/reset", { method: "POST", body: { password } }), + voteStatus: () => apiClient.getVoteStatus(), + reset: (password) => apiClient.reset({ body: { password } }), factoryReset: (password) => - request("/api/admin/factory-reset", { - method: "POST", + apiClient.factoryReset({ body: { password }, }), - grantJoker: (playerId) => - request("/api/admin/joker", { method: "POST", body: { playerId } }), + grantJoker: (playerId) => apiClient.grantJoker({ body: { playerId } }), setPlayerAdmin: (playerId, isAdmin) => - request("/api/admin/player-admin", { - method: "POST", + apiClient.setPlayerAdmin({ body: { playerId, isAdmin }, }), setPlayerPhase: (playerId, phase) => - request("/api/admin/player-phase", { - method: "POST", + apiClient.setPlayerPhase({ body: { playerId, phase }, }), deletePlayer: (playerId, password) => - request(`/api/admin/players/${playerId}`, { - method: "DELETE", + apiClient.deletePlayer({ + pathParameters: { playerId }, body: { password }, }), linkSuggestions: (sourceSuggestionId, targetSuggestionId) => - request("/api/admin/link-suggestions", { - method: "POST", + apiClient.linkSuggestions({ body: { sourceSuggestionId, targetSuggestionId }, }), unlinkSuggestions: (suggestionId) => - request("/api/admin/unlink-suggestions", { - method: "POST", + apiClient.unlinkSuggestions({ body: { suggestionId }, }), };