using GameList.Data; using GameList.Infrastructure; namespace GameList.Endpoints; public static class StateEndpoints { public static void MapStateEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api").RequireAuthorization(); group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service, StateChangeNotifier notifier) => { ctx.Response.Headers.CacheControl = "private, no-cache"; if (notifier.MatchesCurrentEtag(ctx.Request.Headers.IfNoneMatch)) { ctx.Response.Headers.ETag = notifier.CurrentEtag; return Results.StatusCode(StatusCodes.Status304NotModified); } var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); var result = await service.GetStateAsync(player); return result.ToHttpResult(payload => { ctx.Response.Headers.ETag = notifier.CurrentEtag; return Results.Ok(payload); }); }); group.MapGet("/events/state", async (HttpContext ctx, AppDbContext db, StateChangeNotifier notifier) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); ctx.Response.ContentType = "text/event-stream"; ctx.Response.Headers.CacheControl = "no-cache"; ctx.Response.Headers["X-Accel-Buffering"] = "no"; var observedVersion = notifier.CurrentVersion; await WriteStateEventAsync(ctx, "ready", observedVersion, ctx.RequestAborted); while (!ctx.RequestAborted.IsCancellationRequested) { try { var changeTask = notifier.WaitForChangeAsync(observedVersion, ctx.RequestAborted); var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), ctx.RequestAborted); var completed = await Task.WhenAny(changeTask, heartbeatTask); if (completed == changeTask) { observedVersion = await changeTask; await WriteStateEventAsync(ctx, "state", observedVersion, ctx.RequestAborted); } else { await ctx.Response.WriteAsync(": ping\n\n", ctx.RequestAborted); await ctx.Response.Body.FlushAsync(ctx.RequestAborted); } } catch (OperationCanceledException) { break; } catch (IOException) { break; } } return Results.Empty; }); group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); var result = await service.GetMeAsync(player); return result.ToHttpResult(Results.Ok); }); group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); var result = await service.NextPhaseAsync(player); return result.ToHttpResult(Results.Ok); }); group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); var result = await service.PrevPhaseAsync(player); return result.ToHttpResult(Results.Ok); }); } private static async Task WriteStateEventAsync(HttpContext ctx, string eventName, long version, CancellationToken cancellationToken) { await ctx.Response.WriteAsync($"event: {eventName}\n", cancellationToken); await ctx.Response.WriteAsync($"data: {version}\n\n", cancellationToken); await ctx.Response.Body.FlushAsync(cancellationToken); } }