Refactor API/service boundaries and modularize frontend

This commit is contained in:
2026-02-24 23:33:12 +01:00
parent 1d512d321b
commit c6e95f16e1
39 changed files with 1628 additions and 1315 deletions

View File

@@ -0,0 +1,20 @@
namespace RpgRoller.Api;
public static class ApiEndpointRegistration
{
public static void MapRpgRollerApi(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api");
api.MapSystemEndpoints();
api.MapAuthEndpoints();
var authenticatedApi = api.MapGroup(string.Empty)
.AddEndpointFilter<RequireSessionTokenFilter>();
authenticatedApi.MapMeEndpoints();
authenticatedApi.MapCampaignEndpoints();
authenticatedApi.MapCharacterEndpoints();
authenticatedApi.MapSkillEndpoints();
authenticatedApi.MapStateEventEndpoints();
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class ApiResultMapper
{
public static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result)
{
if (result.Succeeded)
{
return TypedResults.Ok(result.Value!);
}
if (result.Error!.Code == "unauthorized")
{
return TypedResults.Unauthorized();
}
return TypedResults.BadRequest(new ApiError(result.Error.Message));
}
public static BadRequest<ApiError> ToBadRequest(ServiceError error)
{
return TypedResults.BadRequest(new ApiError(error.Message));
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class AuthEndpoints
{
public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/auth/register", Results<Ok<UserSummary>, BadRequest<ApiError>> (RegisterRequest request, IGameService game) =>
{
var result = game.Register(request.ToCommand());
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
return TypedResults.Ok(result.Value!);
});
group.MapPost("/auth/login", Results<Ok<UserSummary>, BadRequest<ApiError>> (LoginRequest request, HttpContext context, IGameService game) =>
{
var result = game.Login(request.ToCommand());
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
IsEssential = true,
Secure = context.Request.IsHttps
});
return TypedResults.Ok(result.Value.User);
});
group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
{
if (context.TryReadSessionTokenFromCookie(out var sessionToken))
{
game.Logout(sessionToken);
}
context.Response.Cookies.Delete(SessionCookie.Name);
return TypedResults.NoContent();
});
return group;
}
}

View File

@@ -0,0 +1,36 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class CampaignEndpoints
{
public static RouteGroupBuilder MapCampaignEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/campaigns", (CreateCampaignRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateCampaign(context.GetRequiredSessionToken(), request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/campaigns", (HttpContext context, IGameService game) =>
{
var result = game.GetCampaigns(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
{
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/campaigns/{campaignId:guid}/log", (Guid campaignId, HttpContext context, IGameService game) =>
{
var result = game.GetCampaignLog(context.GetRequiredSessionToken(), campaignId);
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -0,0 +1,36 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class CharacterEndpoints
{
public static RouteGroupBuilder MapCharacterEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/characters", (CreateCharacterRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateCharacter(context.GetRequiredSessionToken(), request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapPut("/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapPost("/characters/{characterId:guid}/activate", (Guid characterId, HttpContext context, IGameService game) =>
{
var result = game.ActivateCharacter(context.GetRequiredSessionToken(), characterId);
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/characters/current-campaign", (HttpContext context, IGameService game) =>
{
var result = game.GetCurrentCampaignCharacters(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class MeEndpoints
{
public static RouteGroupBuilder MapMeEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/me", Results<Ok<MeResponse>, BadRequest<ApiError>, UnauthorizedHttpResult> (HttpContext context, IGameService game) =>
{
var result = game.GetMe(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -0,0 +1,47 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class RequestMappings
{
public static RegisterCommand ToCommand(this RegisterRequest request)
{
return new RegisterCommand(request.Username, request.Password, request.DisplayName);
}
public static LoginCommand ToCommand(this LoginRequest request)
{
return new LoginCommand(request.Username, request.Password);
}
public static CreateCampaignCommand ToCommand(this CreateCampaignRequest request)
{
return new CreateCampaignCommand(request.Name, request.RulesetId);
}
public static CreateCharacterCommand ToCommand(this CreateCharacterRequest request)
{
return new CreateCharacterCommand(request.Name, request.CampaignId);
}
public static UpdateCharacterCommand ToCommand(this UpdateCharacterRequest request)
{
return new UpdateCharacterCommand(request.Name, request.CampaignId);
}
public static CreateSkillCommand ToCommand(this CreateSkillRequest request)
{
return new CreateSkillCommand(request.Name, request.DiceRollDefinition);
}
public static UpdateSkillCommand ToCommand(this UpdateSkillRequest request)
{
return new UpdateSkillCommand(request.Name, request.DiceRollDefinition);
}
public static RollSkillCommand ToCommand(this RollSkillRequest request)
{
return new RollSkillCommand(request.Visibility);
}
}

View File

@@ -0,0 +1,15 @@
namespace RpgRoller.Api;
internal sealed class RequireSessionTokenFilter : IEndpointFilter
{
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken))
{
return ValueTask.FromResult<object?>(TypedResults.Unauthorized());
}
context.HttpContext.StoreSessionToken(sessionToken);
return next(context);
}
}

View File

@@ -0,0 +1,6 @@
namespace RpgRoller.Api;
internal static class SessionCookie
{
public const string Name = "rpgroller_session";
}

View File

@@ -0,0 +1,29 @@
namespace RpgRoller.Api;
internal static class SessionTokenHttpContextExtensions
{
private const string SessionTokenItemKey = "__rpgroller.session-token";
public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken)
{
sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty;
return !string.IsNullOrWhiteSpace(sessionToken);
}
public static void StoreSessionToken(this HttpContext context, string sessionToken)
{
context.Items[SessionTokenItemKey] = sessionToken;
}
public static string GetRequiredSessionToken(this HttpContext context)
{
if (context.Items.TryGetValue(SessionTokenItemKey, out var token)
&& token is string sessionToken
&& !string.IsNullOrWhiteSpace(sessionToken))
{
return sessionToken;
}
throw new InvalidOperationException("Session token is not available in this request.");
}
}

View File

@@ -0,0 +1,30 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class SkillEndpoints
{
public static RouteGroupBuilder MapSkillEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -0,0 +1,66 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class StateEventEndpoints
{
public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/events/state", async Task<IResult> (
Guid campaignId,
HttpContext context,
IGameService game) =>
{
var sessionToken = context.GetRequiredSessionToken();
var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!versionResult.Succeeded)
{
return versionResult.Error!.Code == "unauthorized"
? TypedResults.Unauthorized()
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
}
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
context.Response.ContentType = "text/event-stream";
var lastVersion = versionResult.Value;
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
await context.Response.Body.FlushAsync();
try
{
while (!context.RequestAborted.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!currentVersionResult.Succeeded)
{
break;
}
if (currentVersionResult.Value != lastVersion)
{
lastVersion = currentVersionResult.Value;
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
}
else
{
await context.Response.WriteAsync(": heartbeat\n\n");
}
await context.Response.Body.FlushAsync();
}
}
catch (OperationCanceledException)
{
}
return TypedResults.Empty;
});
return group;
}
}

View File

@@ -0,0 +1,14 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class SystemEndpoints
{
public static RouteGroupBuilder MapSystemEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/health", () => TypedResults.Ok(new HealthResponse("ok")));
group.MapGet("/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets()));
return group;
}
}