From 953b11b36fb655710357fc6d6c2c479f1681eb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 25 Oct 2026 21:49:20 +0100 Subject: [PATCH] fix: answer 400 for a null or missing stay in the booking sample (#465) System.Text.Json doesn't check nullable annotations by default, so "stay":null, or a body with no stay, bound BookingRequest.Stay as null. CheckStay then reached its throwing discard arm and the sample answered 500 with an UnreachableException. CheckStay now takes a DateRange? and answers "Give the stay." for null, and the walkthrough quotes the new code and says why null arrives. Found-By: RV Defect-Class: JSON Co-Authored-By: Claude Opus 5.5 (cherry picked from commit a32d2a554b7951585f68d0c2b093a6f19bd50a4d) --- docs/walkthrough.md | 6 +++++- samples/Bookings/BookingApi.cs | 3 ++- .../WalkthroughSampleTests.cs | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 33aab88..9d77764 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -189,8 +189,9 @@ and a switch over them says what is wrong: ```csharp -static string? CheckStay(DateRange stay) => stay switch +static string? CheckStay(DateRange? stay) => stay switch { + null => "Give the stay.", DateRange.Finite { Length: > 30 } => "A stay is at most 30 nights.", DateRange.Finite => null, DateRange.EmptyRange => "The stay has no nights.", @@ -211,6 +212,9 @@ JSON body, the converter throws. Either way the response is a 400 with an empty Good to know: +- **A JSON `null` reaches the handler.** System.Text.Json doesn't check nullable annotations unless + you turn on `RespectNullableAnnotations`. So `"stay":null`, or a body with no `stay`, binds as + `null` although `Stay` isn't nullable, and the `null` arm turns it into a 400. - **The discard arm is there for the compiler.** The five shapes are the only ones that can exist, but C# can't prove it. [Pattern matching](ranges.md#pattern-matching) explains why the discard should throw. diff --git a/samples/Bookings/BookingApi.cs b/samples/Bookings/BookingApi.cs index 33f48a1..bbb2dae 100644 --- a/samples/Bookings/BookingApi.cs +++ b/samples/Bookings/BookingApi.cs @@ -108,8 +108,9 @@ static async Task GetAvailability(int id, DateRange window, BookingCont return TypedResults.Ok(new Availability(id, window, booked, free)); } - static string? CheckStay(DateRange stay) => stay switch + static string? CheckStay(DateRange? stay) => stay switch { + null => "Give the stay.", DateRange.Finite { Length: > 30 } => "A stay is at most 30 nights.", DateRange.Finite => null, DateRange.EmptyRange => "The stay has no nights.", diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/WalkthroughSampleTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/WalkthroughSampleTests.cs index 367838a..7259dbe 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/WalkthroughSampleTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/WalkthroughSampleTests.cs @@ -77,7 +77,7 @@ await sample.Expect(HttpStatusCode.Created, } [TestMethod] - public async Task Validation_RejectsAnOpenStay_AStayOutOfSeason_AndALiteralThatDoesNotParse() + public async Task Validation_RejectsANullStay_AnOpenStay_AStayOutOfSeason_AndALiteralThatDoesNotParse() { await using var sample = await SampleHost.StartAsync(); @@ -91,6 +91,11 @@ await sample.Expect(HttpStatusCode.BadRequest, Problem + """{"stay":["A stay is sample.Post("""{"roomId":1,"guest":"Linus","stay":"[2026-08-01,2026-09-01)"}""")); await sample.Expect(HttpStatusCode.Created, null, sample.Post("""{"roomId":1,"guest":"Linus","stay":"[2026-08-01,2026-08-31)"}""")); + // System.Text.Json binds a JSON null, or no stay at all, as null whatever the annotation (#465). + await sample.Expect(HttpStatusCode.BadRequest, Problem + """{"stay":["Give the stay."]}}""", + sample.Post("""{"roomId":1,"guest":"Linus","stay":null}""")); + await sample.Expect(HttpStatusCode.BadRequest, Problem + """{"stay":["Give the stay."]}}""", + sample.Post("""{"roomId":1,"guest":"Linus"}""")); await sample.Expect(HttpStatusCode.BadRequest, Problem + """{"guest":["Name the guest."]}}""", sample.Post("""{"roomId":1,"stay":"[2026-09-01,2026-09-03)"}""")); await sample.Expect(HttpStatusCode.BadRequest, Problem + """{"stay":["Garden takes bookings for [2026-05-01,2026-09-30] only."]}}""",