Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Hoist canonical JsonSerializerOptions into foundation-contracts

## Parent
085-analyzer-and-source-gen-opportunities-to-remove-inference-collected-candidates

## Description

The client's canonical serializer options (CamelCase) exist in three places by convention:
web-spa `program.cs` DI config, `web-spa-integration-tests`, and
`web-contracts-tests/contract-serialization.cs` (whose Design region documents this candidate).
Client and tests can silently diverge today. Refactor, not analyzer: one static declaration in
foundation-contracts (e.g. `ContractSerializationDefaults.Options`), referenced by all three —
and by `MockWebApiService` if it serializes.

## Checklist

- [x] `ContractSerializationDefaults` in foundation-contracts/types: shared `Options` instance
(STJ freezes on first use; no seam participant mutates) + `Apply` for the DI
`Configure<JsonSerializerOptions>` pattern.
- [x] web-spa `program.cs` → `Configure<JsonSerializerOptions>(ContractSerializationDefaults.Apply)`.
- [x] Both test projects converted; local declarations deleted.
- [x] The sweep found **nine** declaration sites, not three: web-spa program.cs, **four copies in
timewarp-testing** (test-server-application + api/web/yarp app variants), web-spa-integration
×3 (Aspire test app DI, weather serialization test, the Person smoke test — which was using
*default* options and not exercising the seam at all), api-server-integration convention, and
web-contracts-tests. All now reference the canonical authority.
- [x] Full build 0/0; suites green: contracts 7, web-server integration 22, api-server integration
6, analyzers 26, sourcegen 16.

## Results

**Latent mismatch found and fixed:** `ApiServerTestConvention` registered
`new JsonSerializerOptions()` — **default/PascalCase** — as the DI singleton for the api test
client, disagreeing with the camelCase seam everywhere else (masked by case-insensitive
deserialization paths). Now canonical; api integration tests pass under the corrected options.

## Notes

- Foundation package bump not required beyond the running beta train (ships with next publish).
- Server side deliberately untouched: ASP.NET Core's Web defaults are camelCase and
framework-managed; the canonical type documents that the seam matches them.

This file was deleted.

11 changes: 2 additions & 9 deletions source/container-apps/web/web-spa/program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,8 @@ public static void ConfigureServices(IServiceCollection serviceCollection, IConf
#endif

// Set the JSON serializer options
serviceCollection.Configure<JsonSerializerOptions>
(
jsonSerializerOptions =>
{
//jsonSerializerOptions.PropertyNameCaseInsensitive = false;
jsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
;//jsonSerializerOptions.WriteIndented = true;
}
);
// Contract-seam serialization is declared once in ContractSerializationDefaults.
serviceCollection.Configure<JsonSerializerOptions>(ContractSerializationDefaults.Apply);

#if grpc
SuperheroModule.ConfigureServices(serviceCollection, configuration);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#region Purpose
// Single authority for the JSON serializer options used across the contract seam.
#endregion

#region Design
// The contract seam (SPA client, mock services, test harnesses, contract round-trip tests) must
// serialize identically or drift silently: camelCase JSON matching ASP.NET Core's Web defaults on
// the server side. Declaring the options once here removes the copies that previously agreed only
// by convention. Options is a shared instance (System.Text.Json freezes options on first use;
// no seam participant mutates them); Apply targets DI's Configure<JsonSerializerOptions> pattern.
#endregion

namespace TimeWarp.Foundation.Types;

public static class ContractSerializationDefaults
{
/// <summary>The canonical contract-seam serializer options (camelCase, matching server Web defaults).</summary>
public static JsonSerializerOptions Options { get; } = CreateOptions();

/// <summary>Applies the canonical settings to an existing instance (DI <c>Configure</c> pattern).</summary>
public static void Apply(JsonSerializerOptions options)
{
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
}

private static JsonSerializerOptions CreateOptions()
{
JsonSerializerOptions options = new();
Apply(options);
return options;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ protected override IWebApiTestService CreateWebApiTestService(WebApplicationHost
IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
IAccessTokenProvider accessTokenProvider = serviceProvider.GetRequiredService<IAccessTokenProvider>();

var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options;
IOptions<JsonSerializerOptions> jsonSerializerOptionsAccessor = Options.Create(jsonSerializerOptions);

var apiService = new ApiServerApiService(httpClientFactory, accessTokenProvider, jsonSerializerOptionsAccessor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ protected override IWebApiTestService CreateWebApiTestService(WebApplicationHost
IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
IAccessTokenProvider accessTokenProvider = serviceProvider.GetRequiredService<IAccessTokenProvider>();

var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options;
IOptions<JsonSerializerOptions> jsonSerializerOptionsAccessor = Options.Create(jsonSerializerOptions);

var webServerApiService = new WebServerApiService(accessTokenProvider, httpClientFactory, jsonSerializerOptionsAccessor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ protected static void ConfigureServicesCallback(IServiceCollection serviceCollec

protected override IWebApiTestService CreateWebApiTestService(WebApplicationHost<Yarp.Server.Program> webApplicationHost)
{
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options;
var apiService = new ApiServerApiService(HttpClient, new MockAccessTokenProvider(), jsonSerializerOptions);
return new WebApiTestService(apiService);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/common/timewarp-testing/test-server-application.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ protected override IWebApiTestService CreateWebApiTestService(WebApplicationHost
IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
IAccessTokenProvider accessTokenProvider = serviceProvider.GetRequiredService<IAccessTokenProvider>();

var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options;
IOptions<JsonSerializerOptions> jsonSerializerOptionsAccessor = Options.Create(jsonSerializerOptions);

var apiService = new ApiServerApiService(httpClientFactory, accessTokenProvider, jsonSerializerOptionsAccessor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ private static void ConfigureServices(ServiceCollection serviceCollection)
}
);

// Register JsonSerializerOptions
serviceCollection.AddSingleton(new JsonSerializerOptions());
// Register the canonical contract-seam JsonSerializerOptions (was default/PascalCase — a
// latent mismatch with the camelCase seam).
serviceCollection.AddSingleton(ContractSerializationDefaults.Options);

// Register the access token provider
serviceCollection.AddSingleton<IAccessTokenProvider, MockAccessTokenProvider>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
#endregion

#region Design
// Options mirror the SPA client's DI configuration (web-spa program.cs: CamelCase naming) — the
// seam these tests guard is "what the client writes is what the client reads back".
// Candidate improvement: hoist the canonical JsonSerializerOptions into foundation-contracts so
// the client, mocks, and these tests share one declaration instead of agreeing by convention.
// Options come from ContractSerializationDefaults — the single authority the SPA client and test
// harnesses share — so these tests exercise the exact seam the app uses.
// Trivial auto-property POCO round-trips are deliberately NOT written here: they cannot fail
// under default System.Text.Json. Tests target shapes where serialization can actually diverge —
// parameterized ctors with Guard clauses, ListResponse<T> envelopes, source-generated route
Expand All @@ -17,10 +15,7 @@ namespace TimeWarp.Architecture.Web.Contracts.Tests;

internal static class ContractSerialization
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static readonly JsonSerializerOptions Options = TimeWarp.Foundation.Types.ContractSerializationDefaults.Options;

public static T RoundTrip<T>(T value) where T : class
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public class Should
public static void SerializeAndDeserialize()
{
//Arrange
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options;
var weatherForecast = new TWeatherForecast
(
date: new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@
global using TimeWarp.State;

global using TimeWarp.Fixie;
global using TimeWarp.Foundation.Types;
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,7 @@ private static void ConfigureServices(IServiceCollection services, string baseUr
services.AddHttpClient(TimeWarp.Foundation.Configuration.ServiceNames.ApiServiceName, c => c.BaseAddress = new Uri(baseUrl));

// Configure JSON serializer options
services.Configure<JsonSerializerOptions>(options =>
{
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
services.Configure<JsonSerializerOptions>(ContractSerializationDefaults.Apply);

// Register IAccessTokenProvider (required for API service)
IAccessTokenProvider fakeAccessTokenProvider = A.Fake<IAccessTokenProvider>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public class JsonSerializer_Should
{
public void SerializeAndDeserializePerson()
{
var jsonSerializerOptions = new JsonSerializerOptions();
JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options;
var person = new Person { FirstName = "Steve", LastName = "Cramer", BirthDay = new DateTime(1967, 09, 27) };
string json = JsonSerializer.Serialize(person, jsonSerializerOptions);
Person parsed = JsonSerializer.Deserialize<Person>(json, jsonSerializerOptions)!;
Expand Down
Loading