Add payload serializer guardrails
This commit is contained in:
@@ -101,8 +101,9 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
|
||||
- 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.
|
||||
- Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload.
|
||||
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 50-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`.
|
||||
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`.
|
||||
- Campaign log rows now ship compact summary data first and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded.
|
||||
- Hot API contracts share a source-generated `System.Text.Json` context, and HTTP JSON responses are gzip-compressed when the client advertises support.
|
||||
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
|
||||
|
||||
## Test and Coverage
|
||||
@@ -111,6 +112,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
|
||||
```powershell
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
||||
```
|
||||
- Regression tests enforce payload budgets for the hottest contracts: character sheet reads, initial log page loads, incremental log updates, and roll mutation responses.
|
||||
- Coverage gate:
|
||||
```powershell
|
||||
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
||||
|
||||
27
RpgRoller.Tests/Api/ResponseCompressionApiTests.cs
Normal file
27
RpgRoller.Tests/Api/ResponseCompressionApiTests.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ResponseCompressionApiTests : ApiTestBase
|
||||
{
|
||||
public ResponseCompressionApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedJsonResponses_EnableGzipCompression()
|
||||
{
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "gm-compress", "Password123", "GM");
|
||||
await LoginAsync(client, "gm-compress", "Password123");
|
||||
_ = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Compressed", "d6"));
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns");
|
||||
request.Headers.Add("Accept-Encoding", "gzip");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Contains("gzip", response.Content.Headers.ContentEncoding);
|
||||
}
|
||||
}
|
||||
127
RpgRoller.Tests/PayloadBudgetTests.cs
Normal file
127
RpgRoller.Tests/PayloadBudgetTests.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class PayloadBudgetTests
|
||||
{
|
||||
[Fact]
|
||||
public void CharacterSheetPayload_StaysWithinBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-sheet", "Password123", "GM");
|
||||
service.Register("owner-sheet", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-sheet", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-sheet", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Sheet", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Payload Hero", campaign.Id));
|
||||
|
||||
var groupIds = new List<Guid>
|
||||
{
|
||||
ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Combat", "2D+1", 1, true)).Id,
|
||||
ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Social", "2D+1", 1, true)).Id,
|
||||
ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Knowledge", "2D+1", 1, true)).Id,
|
||||
ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Survival", "2D+1", 1, true)).Id
|
||||
};
|
||||
|
||||
for (var i = 0; i < 18; i++)
|
||||
{
|
||||
Guid? skillGroupId = i < 16 ? groupIds[i % groupIds.Count] : null;
|
||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, $"Skill {i:D2}", "2D+1", 1, true, skillGroupId));
|
||||
}
|
||||
|
||||
var sheet = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, character.Id));
|
||||
AssertPayloadWithinBudget(sheet, 12 * 1024, "initial character sheet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogInitialPagePayload_StaysWithinBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(220));
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-log-budget", "Password123", "GM");
|
||||
service.Register("owner-log-budget", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-log-budget", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-log-budget", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Log", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Log Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var page = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25));
|
||||
AssertPayloadWithinBudget(page, 8 * 1024, "initial log page");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogIncrementalPayload_StaysWithinBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(240));
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-log-delta", "Password123", "GM");
|
||||
service.Register("owner-log-delta", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-log-delta", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-log-delta", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Delta", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Delta Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var initialPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25));
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var incrementalPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, initialPage.Cursor, 25));
|
||||
AssertPayloadWithinBudget(incrementalPage, 2 * 1024, "incremental log update");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollResultPayload_StaysWithinJsInteropBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(40));
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-roll-budget", "Password123", "GM");
|
||||
service.Register("owner-roll-budget", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-roll-budget", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-roll-budget", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Roll", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Roll Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Wild Roll", "6D+3", 3, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
AssertPayloadWithinBudget(roll, 16 * 1024, "roll mutation response");
|
||||
}
|
||||
|
||||
private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label)
|
||||
{
|
||||
var byteCount = JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions).Length;
|
||||
Assert.True(byteCount <= maxBytes, $"{label} payload was {byteCount} bytes, above the {maxBytes}-byte budget.");
|
||||
}
|
||||
|
||||
private static int[] CreateScriptedRolls(int count)
|
||||
{
|
||||
var values = new[] { 6, 5, 4, 3, 2, 1 };
|
||||
var scriptedRolls = new int[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
scriptedRolls[i] = values[i % values.Length];
|
||||
|
||||
return scriptedRolls;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
}
|
||||
@@ -1236,7 +1236,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
private const string RollVisibilitySessionKey = "roll-visibility";
|
||||
private const int CampaignLogWindowSize = 50;
|
||||
private const int CampaignLogWindowSize = 25;
|
||||
private const int ToastDurationMs = 3200;
|
||||
|
||||
private sealed record WorkspaceToast(Guid Id, string Message, bool IsError);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components;
|
||||
|
||||
@@ -37,7 +38,7 @@ public sealed class RpgRollerApiClient
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly JsonSerializerOptions JsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
private readonly IJSRuntime m_Js;
|
||||
}
|
||||
|
||||
|
||||
19
RpgRoller/Contracts/RpgRollerJson.cs
Normal file
19
RpgRoller/Contracts/RpgRollerJson.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RpgRoller.Contracts;
|
||||
|
||||
public static class RpgRollerJson
|
||||
{
|
||||
public static JsonSerializerOptions CreateSerializerOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
Configure(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
public static void Configure(JsonSerializerOptions options)
|
||||
{
|
||||
if (!options.TypeInfoResolverChain.Contains(RpgRollerJsonSerializerContext.Default))
|
||||
options.TypeInfoResolverChain.Insert(0, RpgRollerJsonSerializerContext.Default);
|
||||
}
|
||||
}
|
||||
58
RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs
Normal file
58
RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RpgRoller.Contracts;
|
||||
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||
[JsonSerializable(typeof(ApiError))]
|
||||
[JsonSerializable(typeof(AdminUserSummary))]
|
||||
[JsonSerializable(typeof(AdminUserSummary[]))]
|
||||
[JsonSerializable(typeof(CampaignGmSummary))]
|
||||
[JsonSerializable(typeof(CampaignLogEntry))]
|
||||
[JsonSerializable(typeof(CampaignLogEntry[]))]
|
||||
[JsonSerializable(typeof(CampaignLogListEntry))]
|
||||
[JsonSerializable(typeof(CampaignLogListEntry[]))]
|
||||
[JsonSerializable(typeof(CampaignLogPage))]
|
||||
[JsonSerializable(typeof(CampaignOption))]
|
||||
[JsonSerializable(typeof(CampaignOption[]))]
|
||||
[JsonSerializable(typeof(CampaignRollDetail))]
|
||||
[JsonSerializable(typeof(CampaignRoster))]
|
||||
[JsonSerializable(typeof(CampaignStateSnapshot))]
|
||||
[JsonSerializable(typeof(CampaignSummary))]
|
||||
[JsonSerializable(typeof(CampaignSummary[]))]
|
||||
[JsonSerializable(typeof(CharacterSheet))]
|
||||
[JsonSerializable(typeof(CharacterSheetSkill))]
|
||||
[JsonSerializable(typeof(CharacterSheetSkillGroup))]
|
||||
[JsonSerializable(typeof(CharacterStateVersion))]
|
||||
[JsonSerializable(typeof(CharacterStateVersion[]))]
|
||||
[JsonSerializable(typeof(CharacterSummary))]
|
||||
[JsonSerializable(typeof(CharacterSummary[]))]
|
||||
[JsonSerializable(typeof(CreateCampaignRequest))]
|
||||
[JsonSerializable(typeof(CreateCharacterRequest))]
|
||||
[JsonSerializable(typeof(CreateSkillGroupRequest))]
|
||||
[JsonSerializable(typeof(CreateSkillRequest))]
|
||||
[JsonSerializable(typeof(HealthResponse))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<AdminUserSummary>))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<CharacterStateVersion>))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<RollDieResult>))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<string>))]
|
||||
[JsonSerializable(typeof(LoginRequest))]
|
||||
[JsonSerializable(typeof(MeResponse))]
|
||||
[JsonSerializable(typeof(RegisterRequest))]
|
||||
[JsonSerializable(typeof(RollDieResult))]
|
||||
[JsonSerializable(typeof(RollDieResult[]))]
|
||||
[JsonSerializable(typeof(RollResult))]
|
||||
[JsonSerializable(typeof(RollSkillRequest))]
|
||||
[JsonSerializable(typeof(RulesetDefinition))]
|
||||
[JsonSerializable(typeof(RulesetDefinition[]))]
|
||||
[JsonSerializable(typeof(SkillGroupSummary))]
|
||||
[JsonSerializable(typeof(SkillSummary))]
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
[JsonSerializable(typeof(UpdateCharacterRequest))]
|
||||
[JsonSerializable(typeof(UpdateSkillGroupRequest))]
|
||||
[JsonSerializable(typeof(UpdateSkillRequest))]
|
||||
[JsonSerializable(typeof(UpdateUserRolesRequest))]
|
||||
[JsonSerializable(typeof(UserSummary))]
|
||||
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using RpgRoller.Api;
|
||||
using RpgRoller.Components;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Hosting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddResponseCompression(options =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
|
||||
});
|
||||
builder.Services.ConfigureHttpJsonOptions(options => RpgRollerJson.Configure(options.SerializerOptions));
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<RpgRollerApiClient>();
|
||||
builder.Services.AddScoped<WorkspaceSessionTokenAccessor>();
|
||||
@@ -25,6 +33,7 @@ if (!string.IsNullOrWhiteSpace(configuredPathBase))
|
||||
app.UsePathBase(normalizedPathBase);
|
||||
}
|
||||
|
||||
app.UseResponseCompression();
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
|
||||
@@ -797,7 +797,7 @@ public sealed class GameService : IGameService
|
||||
|
||||
var (user, campaign) = context.Value!;
|
||||
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign)
|
||||
.TakeLast(CampaignLogPageSize)
|
||||
.TakeLast(CampaignLogHistoryWindowSize)
|
||||
.Select(ToLogEntry)
|
||||
.ToArray();
|
||||
|
||||
@@ -1596,9 +1596,9 @@ public sealed class GameService : IGameService
|
||||
private static int NormalizeCampaignLogPageSize(int? limit)
|
||||
{
|
||||
if (!limit.HasValue)
|
||||
return CampaignLogPageSize;
|
||||
return CampaignLogLivePageSize;
|
||||
|
||||
return Math.Clamp(limit.Value, 1, CampaignLogPageSize);
|
||||
return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize);
|
||||
}
|
||||
|
||||
private static UserAccount CloneUser(UserAccount user)
|
||||
@@ -1692,8 +1692,9 @@ public sealed class GameService : IGameService
|
||||
};
|
||||
}
|
||||
|
||||
private const int CampaignLogPageSize = 100;
|
||||
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private const int CampaignLogHistoryWindowSize = 100;
|
||||
private const int CampaignLogLivePageSize = 25;
|
||||
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
|
||||
private readonly Dictionary<Guid, CampaignStateTracker> m_CampaignStateById = [];
|
||||
private readonly Dictionary<Guid, Character> m_CharactersById = [];
|
||||
|
||||
Reference in New Issue
Block a user