diff --git a/kanban/done/085-002-hoist-canonical-jsonserializeroptions-into-foundation-contracts.md b/kanban/done/085-002-hoist-canonical-jsonserializeroptions-into-foundation-contracts.md new file mode 100644 index 000000000..23fc82403 --- /dev/null +++ b/kanban/done/085-002-hoist-canonical-jsonserializeroptions-into-foundation-contracts.md @@ -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` pattern. +- [x] web-spa `program.cs` → `Configure(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. diff --git a/kanban/to-do/085-002-hoist-canonical-jsonserializeroptions-into-foundation-contracts.md b/kanban/to-do/085-002-hoist-canonical-jsonserializeroptions-into-foundation-contracts.md deleted file mode 100644 index ecf70ec2b..000000000 --- a/kanban/to-do/085-002-hoist-canonical-jsonserializeroptions-into-foundation-contracts.md +++ /dev/null @@ -1,26 +0,0 @@ -# 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 - -- [ ] Add the canonical options to foundation-contracts (with Purpose/Design regions explaining - it is THE seam definition). -- [ ] web-spa `program.cs` `Configure` uses it. -- [ ] Both test projects use it; delete their local declarations. -- [ ] Grep for other `new JsonSerializerOptions` under source/ that mean "the contract seam". -- [ ] Full build + all fast test suites green. - -## Notes - -- Foundation package bump not required beyond the running beta train (ships with next publish). diff --git a/source/container-apps/web/web-spa/program.cs b/source/container-apps/web/web-spa/program.cs index 549a2482f..cbb0a2f71 100644 --- a/source/container-apps/web/web-spa/program.cs +++ b/source/container-apps/web/web-spa/program.cs @@ -138,15 +138,8 @@ public static void ConfigureServices(IServiceCollection serviceCollection, IConf #endif // Set the JSON serializer options - serviceCollection.Configure - ( - jsonSerializerOptions => - { - //jsonSerializerOptions.PropertyNameCaseInsensitive = false; - jsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - ;//jsonSerializerOptions.WriteIndented = true; - } - ); + // Contract-seam serialization is declared once in ContractSerializationDefaults. + serviceCollection.Configure(ContractSerializationDefaults.Apply); #if grpc SuperheroModule.ConfigureServices(serviceCollection, configuration); diff --git a/source/foundation/foundation-contracts/types/contract-serialization-defaults.cs b/source/foundation/foundation-contracts/types/contract-serialization-defaults.cs new file mode 100644 index 000000000..4a181b612 --- /dev/null +++ b/source/foundation/foundation-contracts/types/contract-serialization-defaults.cs @@ -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 pattern. +#endregion + +namespace TimeWarp.Foundation.Types; + +public static class ContractSerializationDefaults +{ + /// The canonical contract-seam serializer options (camelCase, matching server Web defaults). + public static JsonSerializerOptions Options { get; } = CreateOptions(); + + /// Applies the canonical settings to an existing instance (DI Configure pattern). + public static void Apply(JsonSerializerOptions options) + { + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + } + + private static JsonSerializerOptions CreateOptions() + { + JsonSerializerOptions options = new(); + Apply(options); + return options; + } +} diff --git a/tests/common/timewarp-testing/applications/api-test-server-application.cs b/tests/common/timewarp-testing/applications/api-test-server-application.cs index bc7f540c5..0afabe2ab 100644 --- a/tests/common/timewarp-testing/applications/api-test-server-application.cs +++ b/tests/common/timewarp-testing/applications/api-test-server-application.cs @@ -46,7 +46,7 @@ protected override IWebApiTestService CreateWebApiTestService(WebApplicationHost IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); IAccessTokenProvider accessTokenProvider = serviceProvider.GetRequiredService(); - var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options; IOptions jsonSerializerOptionsAccessor = Options.Create(jsonSerializerOptions); var apiService = new ApiServerApiService(httpClientFactory, accessTokenProvider, jsonSerializerOptionsAccessor); diff --git a/tests/common/timewarp-testing/applications/web-test-server-application.cs b/tests/common/timewarp-testing/applications/web-test-server-application.cs index a9ebda70b..0800edcb0 100644 --- a/tests/common/timewarp-testing/applications/web-test-server-application.cs +++ b/tests/common/timewarp-testing/applications/web-test-server-application.cs @@ -57,7 +57,7 @@ protected override IWebApiTestService CreateWebApiTestService(WebApplicationHost IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); IAccessTokenProvider accessTokenProvider = serviceProvider.GetRequiredService(); - var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options; IOptions jsonSerializerOptionsAccessor = Options.Create(jsonSerializerOptions); var webServerApiService = new WebServerApiService(accessTokenProvider, httpClientFactory, jsonSerializerOptionsAccessor); diff --git a/tests/common/timewarp-testing/applications/yarp-test-server-application.cs b/tests/common/timewarp-testing/applications/yarp-test-server-application.cs index 74a60d143..9183e4524 100644 --- a/tests/common/timewarp-testing/applications/yarp-test-server-application.cs +++ b/tests/common/timewarp-testing/applications/yarp-test-server-application.cs @@ -51,7 +51,7 @@ protected static void ConfigureServicesCallback(IServiceCollection serviceCollec protected override IWebApiTestService CreateWebApiTestService(WebApplicationHost webApplicationHost) { - var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options; var apiService = new ApiServerApiService(HttpClient, new MockAccessTokenProvider(), jsonSerializerOptions); return new WebApiTestService(apiService); } diff --git a/tests/common/timewarp-testing/test-server-application.cs b/tests/common/timewarp-testing/test-server-application.cs index 9acb11af4..a8a480918 100644 --- a/tests/common/timewarp-testing/test-server-application.cs +++ b/tests/common/timewarp-testing/test-server-application.cs @@ -97,7 +97,7 @@ protected override IWebApiTestService CreateWebApiTestService(WebApplicationHost IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); IAccessTokenProvider accessTokenProvider = serviceProvider.GetRequiredService(); - var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + JsonSerializerOptions jsonSerializerOptions = ContractSerializationDefaults.Options; IOptions jsonSerializerOptionsAccessor = Options.Create(jsonSerializerOptions); var apiService = new ApiServerApiService(httpClientFactory, accessTokenProvider, jsonSerializerOptionsAccessor); diff --git a/tests/container-apps/api/api-server-integration-tests/Infrastructure/ApiServerTestConvention.cs b/tests/container-apps/api/api-server-integration-tests/Infrastructure/ApiServerTestConvention.cs index f599b9734..fd0f5a1e4 100644 --- a/tests/container-apps/api/api-server-integration-tests/Infrastructure/ApiServerTestConvention.cs +++ b/tests/container-apps/api/api-server-integration-tests/Infrastructure/ApiServerTestConvention.cs @@ -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(); diff --git a/tests/container-apps/web/web-contracts-tests/contract-serialization.cs b/tests/container-apps/web/web-contracts-tests/contract-serialization.cs index 97086e313..af98b9e84 100644 --- a/tests/container-apps/web/web-contracts-tests/contract-serialization.cs +++ b/tests/container-apps/web/web-contracts-tests/contract-serialization.cs @@ -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 envelopes, source-generated route @@ -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 value) where T : class { diff --git a/tests/container-apps/web/web-spa-integration-tests/Features/WeatherForecast/WeatherForecastState_Serialization_Tests.cs b/tests/container-apps/web/web-spa-integration-tests/Features/WeatherForecast/WeatherForecastState_Serialization_Tests.cs index 72fac5de4..1cbf1c568 100644 --- a/tests/container-apps/web/web-spa-integration-tests/Features/WeatherForecast/WeatherForecastState_Serialization_Tests.cs +++ b/tests/container-apps/web/web-spa-integration-tests/Features/WeatherForecast/WeatherForecastState_Serialization_Tests.cs @@ -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), diff --git a/tests/container-apps/web/web-spa-integration-tests/GlobalUsings.cs b/tests/container-apps/web/web-spa-integration-tests/GlobalUsings.cs index 4438284ce..321743fa0 100644 --- a/tests/container-apps/web/web-spa-integration-tests/GlobalUsings.cs +++ b/tests/container-apps/web/web-spa-integration-tests/GlobalUsings.cs @@ -22,3 +22,4 @@ global using TimeWarp.State; global using TimeWarp.Fixie; +global using TimeWarp.Foundation.Types; diff --git a/tests/container-apps/web/web-spa-integration-tests/Infrastructure/AspireSpaTestApplication.cs b/tests/container-apps/web/web-spa-integration-tests/Infrastructure/AspireSpaTestApplication.cs index 2135d2b88..33429d46b 100644 --- a/tests/container-apps/web/web-spa-integration-tests/Infrastructure/AspireSpaTestApplication.cs +++ b/tests/container-apps/web/web-spa-integration-tests/Infrastructure/AspireSpaTestApplication.cs @@ -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(options => - { - options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - }); + services.Configure(ContractSerializationDefaults.Apply); // Register IAccessTokenProvider (required for API service) IAccessTokenProvider fakeAccessTokenProvider = A.Fake(); diff --git a/tests/container-apps/web/web-spa-integration-tests/Serialization/JsonSerializerOptions_Serialization_Tests.cs b/tests/container-apps/web/web-spa-integration-tests/Serialization/JsonSerializerOptions_Serialization_Tests.cs index 574dbde6a..f7d6ae49d 100644 --- a/tests/container-apps/web/web-spa-integration-tests/Serialization/JsonSerializerOptions_Serialization_Tests.cs +++ b/tests/container-apps/web/web-spa-integration-tests/Serialization/JsonSerializerOptions_Serialization_Tests.cs @@ -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(json, jsonSerializerOptions)!;