Move workspace reads server-side
This commit is contained in:
@@ -99,6 +99,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
|
||||
|
||||
- Runtime frontend is Blazor Server with interactive components.
|
||||
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
|
||||
- Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers.
|
||||
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and the 100 most recent visible log entries.
|
||||
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
|
||||
|
||||
|
||||
101
RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs
Normal file
101
RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using RpgRoller.Components;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class WorkspaceQueryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers.Cookie = "rpgroller_session=session-token";
|
||||
|
||||
var accessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
var sessionTokenAccessor = new WorkspaceSessionTokenAccessor(accessor);
|
||||
|
||||
Assert.Equal("session-token", sessionTokenAccessor.GetRequiredSessionToken());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCampaignsAsync_UsesCapturedSessionToken()
|
||||
{
|
||||
var service = new StubGameService
|
||||
{
|
||||
GetCampaignsHandler = sessionToken =>
|
||||
{
|
||||
Assert.Equal("server-session", sessionToken);
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new UserSummary(Guid.NewGuid(), "gm", "GM", []), 1)]);
|
||||
}
|
||||
};
|
||||
|
||||
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("server-session"));
|
||||
var campaigns = await queryService.GetCampaignsAsync();
|
||||
|
||||
Assert.Single(campaigns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException()
|
||||
{
|
||||
var service = new StubGameService
|
||||
{
|
||||
GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.")
|
||||
};
|
||||
|
||||
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session"));
|
||||
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
|
||||
|
||||
Assert.Equal(401, exception.StatusCode);
|
||||
Assert.Equal("You must be logged in.", exception.Message);
|
||||
}
|
||||
|
||||
private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}";
|
||||
return new WorkspaceSessionTokenAccessor(new HttpContextAccessor { HttpContext = httpContext });
|
||||
}
|
||||
|
||||
private sealed class StubGameService : IGameService
|
||||
{
|
||||
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } =
|
||||
_ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
|
||||
|
||||
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } =
|
||||
_ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
|
||||
|
||||
public IReadOnlyList<RulesetDefinition> GetRulesets() => throw new NotSupportedException();
|
||||
public ServiceResult<UserSummary> Register(string username, string password, string displayName) => throw new NotSupportedException();
|
||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) => throw new NotSupportedException();
|
||||
public void Logout(string sessionToken) => throw new NotSupportedException();
|
||||
public UserSummary? GetUserBySession(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<MeResponse> GetMe(string sessionToken) => GetMeHandler(sessionToken);
|
||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken) => GetCampaignsHandler(sessionToken);
|
||||
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId) => throw new NotSupportedException();
|
||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException();
|
||||
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException();
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException();
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException();
|
||||
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -58,31 +58,16 @@ public partial class Workspace : IAsyncDisposable
|
||||
|
||||
private async Task CheckHealthAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var health = await ApiClient.RequestAsync<HealthResponse>("GET", "/api/health");
|
||||
if (!string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HasHealthIssue = true;
|
||||
HealthIssueMessage = "Health endpoint returned an unhealthy response.";
|
||||
return;
|
||||
}
|
||||
|
||||
HasHealthIssue = false;
|
||||
HealthIssueMessage = string.Empty;
|
||||
}
|
||||
catch (ApiRequestException)
|
||||
{
|
||||
HasHealthIssue = true;
|
||||
HealthIssueMessage = "Unable to reach API. Retry to continue.";
|
||||
}
|
||||
HasHealthIssue = false;
|
||||
HealthIssueMessage = string.Empty;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task LoadRulesetsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Rulesets = (await ApiClient.RequestAsync<IReadOnlyList<RulesetDefinition>>("GET", "/api/rulesets")).ToList();
|
||||
Rulesets = (await WorkspaceQuery.GetRulesetsAsync()).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
@@ -94,7 +79,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var usernames = await ApiClient.RequestAsync<IReadOnlyList<string>>("GET", "/api/users/usernames");
|
||||
var usernames = await WorkspaceQuery.GetUsernamesAsync();
|
||||
KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
@@ -133,7 +118,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
|
||||
return await WorkspaceQuery.GetMeAsync();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
@@ -143,7 +128,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
|
||||
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
|
||||
{
|
||||
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("GET", "/api/campaigns");
|
||||
var campaigns = await WorkspaceQuery.GetCampaignsAsync();
|
||||
Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
if (Campaigns.Count == 0)
|
||||
@@ -164,7 +149,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
|
||||
private async Task ReloadCharacterCampaignOptionsAsync()
|
||||
{
|
||||
var campaignOptions = await ApiClient.RequestAsync<IReadOnlyList<CampaignOption>>("GET", "/api/campaigns/options");
|
||||
var campaignOptions = await WorkspaceQuery.GetCharacterCampaignOptionsAsync();
|
||||
CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
@@ -185,7 +170,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
try
|
||||
{
|
||||
var campaignId = SelectedCampaignId.Value;
|
||||
SelectedCampaign = await ApiClient.RequestAsync<CampaignRoster>("GET", $"/api/campaigns/{campaignId}");
|
||||
SelectedCampaign = await WorkspaceQuery.GetCampaignAsync(campaignId);
|
||||
SyncSelectedCharacter();
|
||||
|
||||
if (IsPlayScreen && PlaySelectedCharacterId.HasValue && SelectedCharacterId != PlaySelectedCharacterId)
|
||||
@@ -194,7 +179,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
await RefreshSelectedCharacterSheetAsync();
|
||||
|
||||
CampaignLog = IsPlayScreen
|
||||
? (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{campaignId}/log")).ToList()
|
||||
? (await WorkspaceQuery.GetCampaignLogAsync(campaignId)).ToList()
|
||||
: [];
|
||||
|
||||
await EnsureSelectedCharacterActiveAsync();
|
||||
@@ -320,7 +305,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
|
||||
private async Task ReloadAdminUsersAsync()
|
||||
{
|
||||
AdminUsers = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users"))
|
||||
AdminUsers = (await WorkspaceQuery.GetAdminUsersAsync())
|
||||
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
@@ -586,7 +571,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var sheet = await ApiClient.RequestAsync<CharacterSheet>("GET", $"/api/characters/{SelectedCharacterId.Value}/sheet");
|
||||
var sheet = await WorkspaceQuery.GetCharacterSheetAsync(SelectedCharacterId.Value);
|
||||
SelectedCharacterSkillGroups = sheet.SkillGroups
|
||||
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
@@ -956,6 +941,9 @@ public partial class Workspace : IAsyncDisposable
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private WorkspaceQueryService WorkspaceQuery { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
|
||||
80
RpgRoller/Components/WorkspaceQueryService.cs
Normal file
80
RpgRoller/Components/WorkspaceQueryService.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Components;
|
||||
|
||||
public sealed class WorkspaceQueryService
|
||||
{
|
||||
public WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor)
|
||||
{
|
||||
m_GameService = gameService;
|
||||
m_SessionTokenAccessor = sessionTokenAccessor;
|
||||
}
|
||||
|
||||
public Task<MeResponse> GetMeAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetMe(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync()
|
||||
{
|
||||
return Task.FromResult(m_GameService.GetRulesets());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCampaigns(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptionsAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCharacterCampaignOptions(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
public Task<CampaignRoster> GetCampaignAsync(Guid campaignId)
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCampaign(GetRequiredSessionToken(), campaignId)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetUsernamesAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetUsernames(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
public Task<CharacterSheet> GetCharacterSheetAsync(Guid characterId)
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCharacterSheet(GetRequiredSessionToken(), characterId)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CampaignLogEntry>> GetCampaignLogAsync(Guid campaignId)
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCampaignLog(GetRequiredSessionToken(), campaignId)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
private string GetRequiredSessionToken()
|
||||
{
|
||||
return m_SessionTokenAccessor.GetRequiredSessionToken();
|
||||
}
|
||||
|
||||
private static T GetValue<T>(ServiceResult<T> result)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
return result.Value!;
|
||||
|
||||
throw ToApiRequestException(result.Error!);
|
||||
}
|
||||
|
||||
private static ApiRequestException ToApiRequestException(ServiceError error)
|
||||
{
|
||||
var statusCode = error.Code == "unauthorized" ? 401 : 400;
|
||||
return new ApiRequestException(statusCode, error.Message);
|
||||
}
|
||||
|
||||
private readonly IGameService m_GameService;
|
||||
private readonly WorkspaceSessionTokenAccessor m_SessionTokenAccessor;
|
||||
}
|
||||
35
RpgRoller/Components/WorkspaceSessionTokenAccessor.cs
Normal file
35
RpgRoller/Components/WorkspaceSessionTokenAccessor.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using RpgRoller.Api;
|
||||
|
||||
namespace RpgRoller.Components;
|
||||
|
||||
public sealed class WorkspaceSessionTokenAccessor
|
||||
{
|
||||
public WorkspaceSessionTokenAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is null)
|
||||
return;
|
||||
|
||||
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) &&
|
||||
storedToken is string sessionToken &&
|
||||
!string.IsNullOrWhiteSpace(sessionToken))
|
||||
{
|
||||
m_SessionToken = sessionToken;
|
||||
return;
|
||||
}
|
||||
|
||||
if (httpContext.TryReadSessionTokenFromCookie(out sessionToken))
|
||||
m_SessionToken = sessionToken;
|
||||
}
|
||||
|
||||
public string GetRequiredSessionToken()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(m_SessionToken))
|
||||
return m_SessionToken;
|
||||
|
||||
throw new ApiRequestException(401, "You must be logged in.");
|
||||
}
|
||||
|
||||
private const string SessionTokenItemKey = "__rpgroller.session-token";
|
||||
private readonly string? m_SessionToken;
|
||||
}
|
||||
@@ -5,7 +5,10 @@ using RpgRoller.Hosting;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<RpgRollerApiClient>();
|
||||
builder.Services.AddScoped<WorkspaceSessionTokenAccessor>();
|
||||
builder.Services.AddScoped<WorkspaceQueryService>();
|
||||
|
||||
var app = builder.Build();
|
||||
app.InitializeRpgRollerState();
|
||||
|
||||
Reference in New Issue
Block a user