Normalize JSON request-body validation error keys#106
Merged
Conversation
Strong types that fail to deserialize from a JSON body are reported by
the System.Text.Json input formatter under the JSON path ($.value),
whereas data-annotation and model-binding errors key by the property
name (Value). This adds an opt-out normalization in StrongTypes.AspNetCore
that reconciles them, fixes a numeric-converter bug that lost the path
entirely on type-mismatch/null, and codifies the error-key contract in
tests.
- NumericStrongTypeJsonConverterFactory: type-mismatch/null re-entered
Deserialize<T> and surfaced as "$" + "body"; rethrow path-less so the
serializer reattaches the property path ("$.value"), matching the
string converter.
- AddStrongTypes(o => ...): opt-out NormalizeJsonErrorKeys (default on)
rewrites $.-prefixed keys on the [ApiController] ValidationProblemDetails
to the property name, with configurable JsonErrorKeyCasing (default
PascalCase). Wired via PostConfigure so it is order-independent.
- Tests: EntityTests now asserts the ValidationProblemDetails structure +
raw keys for every type (un-normalized baseline); JsonBodyErrorKeyTests
pins both modes over a reference + struct type; normalizer + converter
unit tests lock the details.
- Docs: testing.md, Skill (SKILL.md + aspnetcore.md), package readme.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CoverageLines: 3825 / 5176 (73.9%) Branches: 2038 / 2736 (74.5%) Files changed in this PR
StrongTypes — lines 93.1% (1520/1632), branches 88.2% (997/1130)Booleans — lines 100.0% (14/14), branches 84.6% (22/26)
Collections — lines 90.2% (276/306), branches 85.1% (114/134)
Digits — lines 97.7% (85/87), branches 93.8% (30/32)
Emails — lines 98.6% (68/69), branches 92.5% (37/40)
Enums — lines 100.0% (58/58), branches 95.5% (21/22)
Exceptions — lines 78.9% (15/19), branches 50.0% (7/14)
Maybe — lines 92.1% (151/164), branches 83.9% (94/112)
Nullables — lines 100.0% (12/12), branches 83.3% (20/24)
Numbers — lines 100.0% (84/84), branches 94.4% (17/18)
Result — lines 98.5% (333/338), branches 95.6% (417/436)
Strings — lines 84.7% (133/157), branches 84.4% (54/64)
generated — lines 89.8% (291/324), branches 78.8% (164/208)
StrongTypes.Analyzers — lines 94.7% (322/340), branches 86.8% (132/152)(root) — lines 94.7% (322/340), branches 86.8% (132/152)
StrongTypes.Api — lines 96.7% (327/338), branches 86.3% (88/102)(root) — lines 100.0% (11/11), branches n/a (0/0)
Controllers — lines 96.9% (221/228), branches 86.3% (88/102)
Data — lines 100.0% (54/54), branches n/a (0/0)
Entities — lines 75.0% (12/16), branches n/a (0/0)
Models — lines 100.0% (29/29), branches n/a (0/0)
StrongTypes.AspNetCore — lines 89.3% (151/169), branches 84.5% (71/84)(root) — lines 89.3% (151/169), branches 84.5% (71/84)
StrongTypes.AspNetCore.TestApi — lines 90.7% (39/43), branches 92.5% (37/40)(root) — lines 100.0% (6/6), branches n/a (0/0)
Controllers — lines 89.2% (33/37), branches 92.5% (37/40)
StrongTypes.EfCore — lines 89.3% (117/131), branches 71.4% (40/56)(root) — lines 89.3% (117/131), branches 71.4% (40/56)
StrongTypes.FsCheck — lines 77.4% (96/124), branches n/a (0/0)(root) — lines 77.4% (96/124), branches n/a (0/0)
StrongTypes.OpenApi.Core — lines 91.2% (424/465), branches 83.0% (284/342)(root) — lines 91.7% (254/277), branches 86.2% (162/188)
Inlining — lines 90.4% (170/188), branches 79.2% (122/154)
StrongTypes.OpenApi.Microsoft — lines 39.1% (425/1088), branches 40.3% (171/424)(root) — lines 94.6% (245/259), branches 74.6% (97/130)
Binding — lines 98.1% (103/105), branches 78.6% (55/70)
Collections — lines 100.0% (19/19), branches 100.0% (8/8)
Digits — lines 100.0% (11/11), branches 100.0% (2/2)
Emails — lines 100.0% (11/11), branches 100.0% (2/2)
Inlining — lines 100.0% (7/7), branches 50.0% (1/2)
Maybe — lines 100.0% (12/12), branches 100.0% (2/2)
Numbers — lines 100.0% (8/8), branches 100.0% (2/2)
Strings — lines 100.0% (9/9), branches 100.0% (2/2)
obj/Debug/net10.0/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator — lines 0.0% (0/647), branches 0.0% (0/204)
StrongTypes.OpenApi.Swashbuckle — lines 93.9% (230/245), branches 76.3% (151/198)(root) — lines 98.5% (67/68), branches 90.3% (56/62)
Binding — lines 88.3% (106/120), branches 64.3% (72/112)
Collections — lines 100.0% (6/6), branches 100.0% (4/4)
Digits — lines 100.0% (10/10), branches 100.0% (4/4)
Emails — lines 100.0% (10/10), branches 100.0% (4/4)
Inlining — lines 100.0% (2/2), branches n/a (0/0)
Maybe — lines 100.0% (13/13), branches 75.0% (3/4)
Numbers — lines 100.0% (8/8), branches 100.0% (4/4)
Strings — lines 100.0% (8/8), branches 100.0% (4/4)
StrongTypes.OpenApi.TestApi.Microsoft — lines 41.0% (163/398), branches 31.6% (65/206)(root) — lines 100.0% (14/14), branches 100.0% (2/2)
obj/Debug/net10.0/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator — lines 38.8% (149/384), branches 30.9% (63/204)
StrongTypes.OpenApi.TestApi.Shared — lines 0.0% (0/192), branches n/a (0/0)(root) — lines 0.0% (0/192), branches n/a (0/0)
StrongTypes.OpenApi.TestApi.Swashbuckle — lines 100.0% (11/11), branches 100.0% (2/2)(root) — lines 100.0% (11/11), branches 100.0% (2/2)
|
Surface AddStrongTypes(o => o.NormalizeJsonErrorKeys = ...) in the root readme package table and the SKILL.md AspNetCore entry, so the opt-out parameter is discoverable without opening the package reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KaliCZ
added a commit
that referenced
this pull request
Jun 24, 2026
Reviewing PR #77 (interval strong types) on top of latest main surfaced gaps in error reporting, integration coverage, and docs. This addresses them. Implementation: - IntervalJsonConverterFactory: nested endpoint reads now rethrow path-less so System.Text.Json reattaches the property path. A null/type-mismatch endpoint was surfacing as the document root ("$") instead of "$.value" — the same bug #106 fixed for the numeric converter. Error messages also no longer leak the arity-suffixed CLR name ("ClosedInterval`1"). Unit tests (StrongTypes.Tests): - Contains over open/closed bounds and ToString for every variant, JSON edge cases (missing/extra/reordered properties, non-object tokens, a DateOnly/DateTime endpoint), error-message quality, and the path-less rethrow. Added generator-branch coverage facts per testing.md. API integration tests (StrongTypes.Api*): - Four interval entities/controllers mapped to a single JSON column via the shipped HasIntervalJsonConversion. Dedicated wire-to-DB suite covering the round-trip on both providers, null handling, update, and invalid payloads keyed at "$.value" (the #106 contract). OpenAPI (StrongTypes.OpenApi.*): - Interval schema transformer (Microsoft) and filter (Swashbuckle) render each variant as { Start, End } with required: [Start, End] and per-variant endpoint nullability, inlined like the other wrappers. CloneWireShape now preserves `required`. Tested on all three pipeline configs. Docs: - README, SKILL.md + new references/intervals.md, and the EfCore/OpenApi references and package readmes. Corrected the ComplexProperty claim: EF Core cannot bind the interval's private constructor as a complex type, and column materialization would bypass the Start <= End invariant — the JSON column is the supported shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
A strong type that fails to deserialize from a JSON request body is reported by the System.Text.Json input formatter under the JSON path (
$.value), whereas data-annotation and model-binding errors key by the property name (Value). This reconciles them, fixes a latent numeric-converter bug, and codifies the error-key contract in tests.Investigation surfaced three distinct failure mechanisms (all confirmed against a running pipeline, not assumed):
$.valueValue(PascalCase C# name)$+body— a latent converter bugChanges
NumericStrongTypeJsonConverterFactoryre-enteredDeserialize<T>, losing the property position on type-mismatch/null (key surfaced as$+body). It now rethrows path-less so the serializer reattaches$.value, matching the string converter. Unit-tested (fails before / passes after).AddStrongTypes(o => …)rewrites$.-prefixed keys on the[ApiController]ValidationProblemDetailsto the property name.NormalizeJsonErrorKeysdefaults on;JsonErrorKeyCasingdefaults PascalCase (matches the framework-default data-annotation keys), withCamelCase/StripOnlyfor camelCase apps. Scoped strictly to the MVC validation response (not rawJsonSerializer, not minimal APIs; model-binding keys untouched). Wired viaPostConfigureso it is registration-order-independent.EntityTests(all types) now asserts the fullValidationProblemDetailsshape + the raw$.value/$.nullableValuekeys (un-normalized baseline — the Api harness doesn't callAddStrongTypes).JsonBodyErrorKeyTestsruns onebool normalizetheory across two factory variants over a reference + struct type, pinning every mechanism in both modes, plus a capstone proving normalized keys match real[Required]/[EmailAddress]keys. Normalizer + converter unit tests lock the details.testing.md,Skill/SKILL.md+references/aspnetcore.md, package readme.Caveat
Exact parity with data annotations isn't universally achievable: the body path carries the JSON wire name, validation carries the C# name, and there's no metadata at the rewrite layer — so casing is a per-segment heuristic. It nails the common flat-property case; a custom
[JsonPropertyName("user_name")]can't be recovered from the path. Documented in the reference and the option's XML docs.Verification
StrongTypes.AspNetCore.IntegrationTests: 46 passStrongTypes.Api.IntegrationTests: 827 pass (17 SQL-Server cases skipped — ARM64 host, per the documentedSTRONGTYPES_SKIP_SQLSERVERopt-out)TreatWarningsAsErrors🤖 Generated with Claude Code