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."]}}""",