Refactor API/service boundaries and modularize frontend
This commit is contained in:
20
RpgRoller/Api/ApiEndpointRegistration.cs
Normal file
20
RpgRoller/Api/ApiEndpointRegistration.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
28
RpgRoller/Api/ApiResultMapper.cs
Normal file
28
RpgRoller/Api/ApiResultMapper.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
54
RpgRoller/Api/AuthEndpoints.cs
Normal file
54
RpgRoller/Api/AuthEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
36
RpgRoller/Api/CampaignEndpoints.cs
Normal file
36
RpgRoller/Api/CampaignEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
36
RpgRoller/Api/CharacterEndpoints.cs
Normal file
36
RpgRoller/Api/CharacterEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
19
RpgRoller/Api/MeEndpoints.cs
Normal file
19
RpgRoller/Api/MeEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
47
RpgRoller/Api/RequestMappings.cs
Normal file
47
RpgRoller/Api/RequestMappings.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
RpgRoller/Api/RequireSessionTokenFilter.cs
Normal file
15
RpgRoller/Api/RequireSessionTokenFilter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
6
RpgRoller/Api/SessionCookie.cs
Normal file
6
RpgRoller/Api/SessionCookie.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class SessionCookie
|
||||
{
|
||||
public const string Name = "rpgroller_session";
|
||||
}
|
||||
29
RpgRoller/Api/SessionTokenHttpContextExtensions.cs
Normal file
29
RpgRoller/Api/SessionTokenHttpContextExtensions.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
30
RpgRoller/Api/SkillEndpoints.cs
Normal file
30
RpgRoller/Api/SkillEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
66
RpgRoller/Api/StateEventEndpoints.cs
Normal file
66
RpgRoller/Api/StateEventEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
14
RpgRoller/Api/SystemEndpoints.cs
Normal file
14
RpgRoller/Api/SystemEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user