[1/4] Serializer canonicalization - #19
Conversation
- Add CoveJsonContext source-gen JsonSerializerContext (camelCase, string enums) - Upgrade CoveJson.Default to a frozen, combined source-gen + reflection resolver with a global camelCase string-enum converter for the reflection-fallback path - Relocate CriterionModifierJsonConverter into Cove.Core as a public type-specific converter, registered ahead of the global enum converter so it wins for CriterionModifier - Document the polymorphic-DTO wire convention for future use
- Pin externally-visible / SignalR-contract enum wire names with [JsonStringEnumMemberName]: GenderEnum, CircumcisedEnum, EventType, JobStatus, JobUnitOutcome; note FilterMode's integer DB persistence is independent of its string wire form - Add EnumWireFormatTests: all 27 core enums emit camelCase strings, read from int-or-string, an unregistered enum proves the reflection-fallback path emits strings, and CriterionModifier parity/lenient-read - Add SpecialTypeRoundTripTests: DateTime ISO-8601, Guid 36-char D, byte[] base64; document empty decimal WriteAsString opt-in list - Register the CriterionModifier converter ahead of the global enum factory so the type-specific converter wins (first-match-wins converter ordering)
…enum tests - Assert MVC, SignalR, and Http.Json options serialize an enum to the same camelCase string - Assert the Http.Json path serializes an unregistered DTO enum as a camelCase string (reflection fallback) - Guard existing controller enum wire output stays a camelCase string
…anonical JSON - Add ConfigureHttpJsonOptions so host minimal-API and extension MapEndpoints responses serialize enums as camelCase strings instead of integers - Introduce a single ApplyCanonicalJson helper that copies the resolver, naming, and every converter from CoveJson.Default onto MVC, SignalR, and Http.Json options - Point the integration read harness at CoveJson.Default so tests assert the real wire contract - Target the recognized IntegrationTest environment in the new parity tests
…ical - ConfigService and ScraperService derive their options from CoveJson.Default, keeping WriteIndented / WhenWritingNull flags - DynamicGroups reads filter and query JSON through CoveJson.Default and drops its private CriterionModifier converter, now supplied by the shared canonical converter
…canonical - FieldProvenanceService, ScrapeAttemptService, and UserService (UI prefs) use CoveJson.Default for their DB-blob (de)serialization - Add UserUiPreferences round-trip, no-drift, and pinned camelCase fixture tests proving the persisted blob shape is byte-compatible after the switch
Greptile SummaryThis PR unifies all serialization boundaries (MVC controllers, SignalR, minimal-API / extension endpoints, and manual call sites) onto a single frozen
Confidence Score: 4/5Safe to merge. Wire output for controllers and SignalR is unchanged; the only intentional wire change (integer to string) is on the extension endpoint path and is the stated goal. The core refactor is structurally sound and well-tested. The one design concern is that ApplyCanonicalJson appends converters additively rather than clearing first — the ordering guarantee holds only because all three target options objects happen to start with empty converter lists today. src/Cove.Api/Program.cs (ApplyCanonicalJson converter ordering assumption) and src/Cove.Tests/EnumWireFormatTests.cs (hardcoded enum count 27). Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Serialization Request] --> B{Which boundary?}
B -->|MVC Controller| C[MVC JsonSerializerOptions]
B -->|SignalR Hub| D[SignalR PayloadSerializerOptions]
B -->|Minimal-API / Extension Endpoints| E[HTTP.Json SerializerOptions]
B -->|Manual call site| F[CoveJson.Default directly]
C & D & E -->|ApplyCanonicalJson| G[CoveJson.Default properties copied]
F --> G
G --> H{TypeInfoResolver}
H -->|Registered DTO| I[CoveJsonContext source-gen fast path]
H -->|Unregistered type| J[DefaultJsonTypeInfoResolver reflection fallback]
I --> K[Converters list - first-match-wins]
J --> K
K -->|CriterionModifier| L[CriterionModifierJsonConverter - lenient read]
K -->|Any other enum| M[JsonStringEnumConverter CamelCase]
K -->|Non-enum| N[Framework default]
L & M & N --> O[Wire: camelCase props + camelCase string enums]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Serialization Request] --> B{Which boundary?}
B -->|MVC Controller| C[MVC JsonSerializerOptions]
B -->|SignalR Hub| D[SignalR PayloadSerializerOptions]
B -->|Minimal-API / Extension Endpoints| E[HTTP.Json SerializerOptions]
B -->|Manual call site| F[CoveJson.Default directly]
C & D & E -->|ApplyCanonicalJson| G[CoveJson.Default properties copied]
F --> G
G --> H{TypeInfoResolver}
H -->|Registered DTO| I[CoveJsonContext source-gen fast path]
H -->|Unregistered type| J[DefaultJsonTypeInfoResolver reflection fallback]
I --> K[Converters list - first-match-wins]
J --> K
K -->|CriterionModifier| L[CriterionModifierJsonConverter - lenient read]
K -->|Any other enum| M[JsonStringEnumConverter CamelCase]
K -->|Non-enum| N[Framework default]
L & M & N --> O[Wire: camelCase props + camelCase string enums]
|
Stack 1 of 4 (base:
extension-sdk-base). Foundation for the contract-first pipeline — must land first.Unifies System.Text.Json onto one canonical, source-generated configuration across controllers, SignalR, minimal APIs, and the extension endpoint path. Enums serialize as camelCase strings everywhere (previously integers leaked on the minimal-API/extension paths); reads remain tolerant of both integer and string. Special-type wire policy (DateTime/Guid/byte[]) documented and round-trip tested.
19 files, +766/−108. Wire format for controllers/SignalR is unchanged; the extension endpoint path changes integer→string (the intended fix). Backend tests green.
Review this first; PRs 2–4 stack on top.