feat(backend): move endpoints to FastEndpoints handlers

This commit is contained in:
2026-03-19 15:23:58 -04:00
parent 3ca568c05d
commit 792fc5619b
7 changed files with 136 additions and 53 deletions

View File

@@ -0,0 +1,16 @@
using FastEndpoints;
using SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Simulation.Api.Handlers;
public sealed class GetWorldHandler(WorldService worldService) : EndpointWithoutRequest
{
public override void Configure()
{
Get("/api/world");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken) =>
SendOkAsync(worldService.GetSnapshot(), cancellationToken);
}

View File

@@ -0,0 +1,24 @@
using FastEndpoints;
using SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Simulation.Api.Handlers;
public sealed class GetWorldHealthHandler(WorldService worldService) : EndpointWithoutRequest
{
public override void Configure()
{
Get("/api/world/health");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken)
{
var status = worldService.GetStatus();
return SendOkAsync(new
{
ok = true,
sequence = status.Sequence,
generatedAtUtc = status.GeneratedAtUtc,
}, cancellationToken);
}
}

View File

@@ -0,0 +1,16 @@
using FastEndpoints;
using SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Simulation.Api.Handlers;
public sealed class ResetWorldHandler(WorldService worldService) : EndpointWithoutRequest
{
public override void Configure()
{
Post("/api/world/reset");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken) =>
SendOkAsync(worldService.Reset(), cancellationToken);
}

View File

@@ -0,0 +1,18 @@
using FastEndpoints;
namespace SpaceGame.Simulation.Api.Handlers;
public sealed class RootRedirectHandler : EndpointWithoutRequest
{
public override void Configure()
{
Get("/");
AllowAnonymous();
}
public override Task HandleAsync(CancellationToken cancellationToken)
{
HttpContext.Response.Redirect("/api/world");
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,55 @@
using System.Text.Json;
using FastEndpoints;
using SpaceGame.Simulation.Api.Contracts;
using SpaceGame.Simulation.Api.Simulation;
namespace SpaceGame.Simulation.Api.Handlers;
public sealed class StreamWorldHandler(WorldService worldService) : EndpointWithoutRequest
{
private static readonly JsonSerializerOptions SseJsonOptions = new(JsonSerializerDefaults.Web);
public override void Configure()
{
Get("/api/world/stream");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
HttpContext.Response.Headers.Append("Cache-Control", "no-cache");
HttpContext.Response.Headers.Append("Content-Type", "text/event-stream");
var afterSequenceRaw = HttpContext.Request.Query["afterSequence"].ToString();
_ = long.TryParse(afterSequenceRaw, out var afterSequence);
var scopeKind = HttpContext.Request.Query["scopeKind"].ToString();
if (string.IsNullOrWhiteSpace(scopeKind))
{
scopeKind = HttpContext.Request.Query["scope"].ToString();
}
if (string.IsNullOrWhiteSpace(scopeKind))
{
scopeKind = "universe";
}
var systemId = HttpContext.Request.Query["systemId"].ToString();
var bubbleId = HttpContext.Request.Query["bubbleId"].ToString();
var scope = new ObserverScope(
scopeKind,
string.IsNullOrWhiteSpace(systemId) ? null : systemId,
string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId);
var stream = worldService.Subscribe(scope, afterSequence, cancellationToken);
await HttpContext.Response.WriteAsync(": connected\n\n", cancellationToken);
await HttpContext.Response.Body.FlushAsync(cancellationToken);
await foreach (var delta in stream.ReadAllAsync(cancellationToken))
{
var payload = JsonSerializer.Serialize(delta, SseJsonOptions);
await HttpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken);
await HttpContext.Response.Body.FlushAsync(cancellationToken);
}
}
}

View File

@@ -1,9 +1,7 @@
using SpaceGame.Simulation.Api.Contracts; using FastEndpoints;
using SpaceGame.Simulation.Api.Simulation; using SpaceGame.Simulation.Api.Simulation;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var sseJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
builder.WebHost.UseUrls("http://127.0.0.1:5079"); builder.WebHost.UseUrls("http://127.0.0.1:5079");
builder.Services.AddCors((options) => builder.Services.AddCors((options) =>
@@ -18,61 +16,13 @@ builder.Services.AddCors((options) =>
}); });
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration")); builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation")); builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
builder.Services.AddFastEndpoints();
builder.Services.AddSingleton<WorldService>(); builder.Services.AddSingleton<WorldService>();
builder.Services.AddHostedService<SimulationHostedService>(); builder.Services.AddHostedService<SimulationHostedService>();
var app = builder.Build(); var app = builder.Build();
app.UseCors(); app.UseCors();
app.UseFastEndpoints();
app.MapGet("/", () => Results.Redirect("/api/world"));
app.MapGet("/api/world", (WorldService worldService) => Results.Ok(worldService.GetSnapshot()));
app.MapGet("/api/world/stream", async (HttpContext httpContext, WorldService worldService, CancellationToken cancellationToken) =>
{
httpContext.Response.Headers.Append("Cache-Control", "no-cache");
httpContext.Response.Headers.Append("Content-Type", "text/event-stream");
var afterSequenceRaw = httpContext.Request.Query["afterSequence"].ToString();
_ = long.TryParse(afterSequenceRaw, out var afterSequence);
var scopeKind = httpContext.Request.Query["scopeKind"].ToString();
if (string.IsNullOrWhiteSpace(scopeKind))
{
scopeKind = httpContext.Request.Query["scope"].ToString();
}
if (string.IsNullOrWhiteSpace(scopeKind))
{
scopeKind = "universe";
}
var systemId = httpContext.Request.Query["systemId"].ToString();
var bubbleId = httpContext.Request.Query["bubbleId"].ToString();
var scope = new ObserverScope(
scopeKind,
string.IsNullOrWhiteSpace(systemId) ? null : systemId,
string.IsNullOrWhiteSpace(bubbleId) ? null : bubbleId);
var stream = worldService.Subscribe(scope, afterSequence, cancellationToken);
await httpContext.Response.WriteAsync(": connected\n\n", cancellationToken);
await httpContext.Response.Body.FlushAsync(cancellationToken);
await foreach (var delta in stream.ReadAllAsync(cancellationToken))
{
var payload = JsonSerializer.Serialize(delta, sseJsonOptions);
await httpContext.Response.WriteAsync($"event: world-delta\ndata: {payload}\n\n", cancellationToken);
await httpContext.Response.Body.FlushAsync(cancellationToken);
}
});
app.MapGet("/api/world/health", (WorldService worldService) => Results.Ok(new
{
ok = true,
sequence = worldService.GetStatus().Sequence,
generatedAtUtc = worldService.GetStatus().GeneratedAtUtc,
}));
app.MapPost("/api/world/reset", (WorldService worldService) =>
{
var snapshot = worldService.Reset();
return Results.Ok(snapshot);
});
app.Run(); app.Run();

View File

@@ -6,4 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="FastEndpoints" Version="6.*" />
</ItemGroup>
</Project> </Project>