From 4d27793487d51928a13b627e59a809020cff5264 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 27 May 2026 19:41:14 +0200 Subject: [PATCH 01/11] start testing --- .../RequestDelegateCreationTests.Unions.cs | 130 ++++++++++++++++++ .../SharedTypes.Unions.cs | 15 ++ 2 files changed, 145 insertions(+) create mode 100644 src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs create mode 100644 src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs new file mode 100644 index 000000000000..5c40f0fd5acb --- /dev/null +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Http.Generators.Tests; + +public union TestUnionIntString(int, string); + +public abstract partial class RequestDelegateCreationTests : RequestDelegateCreationTestBase +{ + [Fact] + public void JsonTypeInfo_For_UnionType_HasUnionKind_AndPopulatedCases() + { + var options = JsonSerializerOptions.Default; + var info = options.GetTypeInfo(typeof(TestUnionIntString)); + + Assert.Equal(JsonTypeInfoKind.Union, info.Kind); + Assert.NotNull(info.UnionCases); + Assert.Equal(2, info.UnionCases.Count); + Assert.NotNull(info.UnionConstructor); + } + + [Fact] + public async Task MapAction_ReturnsUnion_BothCases_SerializeTransparently() + { + var source = """ + app.MapGet("/int", () => new UnionIntString(42)); + app.MapGet("/string", () => new UnionIntString("hello union!")); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation); + + // /int → 42 + var intEndpoint = endpoints.OfType().Single(e => e.RoutePattern.RawText == "/int"); + var ctx1 = CreateHttpContext(); + await intEndpoint.RequestDelegate(ctx1); + await VerifyResponseBodyAsync(ctx1, "42"); + + // /string → "hello union!" + var stringEndpoint = endpoints.OfType().Single(e => e.RoutePattern.RawText == "/string"); + var ctx2 = CreateHttpContext(); + await stringEndpoint.RequestDelegate(ctx2); + await VerifyResponseBodyAsync(ctx2, "\"hello union!\""); + } + + [Fact] + public async Task MapAction_TaskAndValueTaskUnion_SerializeActiveCase() + { + var source = """ + app.MapGet("/sync", () => new UnionIntString(42)); + app.MapGet("/task", () => Task.FromResult(new UnionIntString(42))); + app.MapGet("/valuetask", () => ValueTask.FromResult(new UnionIntString(42))); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + foreach (var route in new[] { "/sync", "/task", "/valuetask" }) + { + var endpoint = endpoints.Single(e => e.RoutePattern.RawText == route); + var ctx = CreateHttpContext(); + await endpoint.RequestDelegate(ctx); + await VerifyResponseBodyAsync(ctx, "42"); + } + } + + [Fact] + public async Task MapAction_ReturnsNullableUnionWrapper_HandlesValueAndNull() + { + var source = """ + app.MapGet("/value", () => (UnionIntString?)new UnionIntString(42)); + app.MapGet("/null", () => (UnionIntString?)null); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var valueCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/value").RequestDelegate(valueCtx); + await VerifyResponseBodyAsync(valueCtx, "42"); + + var nullCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/null").RequestDelegate(nullCtx); + await VerifyResponseBodyAsync(nullCtx, "null"); + } + + [Fact] + public async Task MapAction_ReturnsUnionWithNullableCase_HandlesEachCase() + { + var source = """ + app.MapGet("/int", () => new NullableCaseUnion((int?)5)); + app.MapGet("/null", () => new NullableCaseUnion((int?)null)); + app.MapGet("/string", () => new NullableCaseUnion("hi")); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var intCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/int").RequestDelegate(intCtx); + await VerifyResponseBodyAsync(intCtx, "5"); + + var nullCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/null").RequestDelegate(nullCtx); + await VerifyResponseBodyAsync(nullCtx, "null"); + + var stringCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/string").RequestDelegate(stringCtx); + await VerifyResponseBodyAsync(stringCtx, "\"hi\""); + } + + [Fact] + public async Task MapAction_ReturnsObjectCaseUnion_SerializesActiveCase() + { + var source = """ + app.MapGet("/cat", () => new Pet(new Cat("Whiskers"))); + app.MapGet("/dog", () => new Pet(new Dog("Labrador"))); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var catCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/cat").RequestDelegate(catCtx); + await VerifyResponseBodyAsync(catCtx, "{\"name\":\"Whiskers\"}"); + + var dogCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/dog").RequestDelegate(dogCtx); + await VerifyResponseBodyAsync(dogCtx, "{\"breed\":\"Labrador\"}"); + } +} diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs new file mode 100644 index 000000000000..8bf475b503ff --- /dev/null +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System; + +namespace Http; + +#nullable enable + +public union UnionIntString(int, string); + +public union NullableCaseUnion(int?, string); + +public record Cat(string Name); +public record Dog(string Breed); +public union Pet(Cat, Dog); \ No newline at end of file From 3c249c69ac22c9d0f07e6d99ae8281acaaa962ec Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Thu, 28 May 2026 20:58:36 +0200 Subject: [PATCH 02/11] more union test --- .../RequestDelegateCreationTests.Unions.cs | 166 +++++++++++++++--- .../SharedTypes.Unions.cs | 34 +++- 2 files changed, 172 insertions(+), 28 deletions(-) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs index 5c40f0fd5acb..d9b36118d52a 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs @@ -23,27 +23,47 @@ public void JsonTypeInfo_For_UnionType_HasUnionKind_AndPopulatedCases() Assert.NotNull(info.UnionConstructor); } - [Fact] - public async Task MapAction_ReturnsUnion_BothCases_SerializeTransparently() + // Each primitive .NET type, paired with a sibling case of a different JSON + // token kind so dispatch is unambiguous. Verifies STJ's per-converter wire + // format flows through RDF/RDG correctly for every primitive. + [Theory] + [InlineData("/byte", "42", "Number")] + [InlineData("/short", "1234", "Number")] + [InlineData("/int", "42", "Number")] + [InlineData("/long", "9999999999", "Number")] + [InlineData("/decimal", "3.14", "Number")] + [InlineData("/double", "2.5", "Number")] + [InlineData("/bool", "true", "Boolean")] + [InlineData("/guid", "\"00000000-0000-0000-0000-000000000001\"", "String")] + [InlineData("/datetime", "\"2024-05-28T10:00:00\"", "String")] + [InlineData("/char", "\"x\"", "String")] + [InlineData("/string", "\"hi\"", "String")] + public async Task MapAction_ReturnsUnion_PrimitiveCases_SerializeAsExpected( + string route, string expectedBody, string tokenKind) { var source = """ - app.MapGet("/int", () => new UnionIntString(42)); - app.MapGet("/string", () => new UnionIntString("hello union!")); + app.MapGet("/byte", () => new UnionByteString((byte)42)); + app.MapGet("/short", () => new UnionShortString((short)1234)); + app.MapGet("/int", () => new UnionIntString(42)); + app.MapGet("/long", () => new UnionLongString(9999999999L)); + app.MapGet("/decimal", () => new UnionDecimalString(3.14m)); + app.MapGet("/double", () => new UnionDoubleString(2.5)); + app.MapGet("/bool", () => new UnionBoolString(true)); + app.MapGet("/guid", () => new UnionGuidInt(new Guid("00000000-0000-0000-0000-000000000001"))); + app.MapGet("/datetime", () => new UnionDateTimeInt(new DateTime(2024, 5, 28, 10, 0, 0, DateTimeKind.Unspecified))); + app.MapGet("/char", () => new UnionCharInt('x')); + app.MapGet("/string", () => new UnionIntString("hi")); """; var (_, compilation) = await RunGeneratorAsync(source); - var endpoints = GetEndpointsFromCompilation(compilation); - - // /int → 42 - var intEndpoint = endpoints.OfType().Single(e => e.RoutePattern.RawText == "/int"); - var ctx1 = CreateHttpContext(); - await intEndpoint.RequestDelegate(ctx1); - await VerifyResponseBodyAsync(ctx1, "42"); - - // /string → "hello union!" - var stringEndpoint = endpoints.OfType().Single(e => e.RoutePattern.RawText == "/string"); - var ctx2 = CreateHttpContext(); - await stringEndpoint.RequestDelegate(ctx2); - await VerifyResponseBodyAsync(ctx2, "\"hello union!\""); + var endpoint = GetEndpointsFromCompilation(compilation) + .OfType() + .Single(e => e.RoutePattern.RawText == route); + + var ctx = CreateHttpContext(); + await endpoint.RequestDelegate(ctx); + var body = await GetResponseBodyAsync(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + Assert.True(body == expectedBody, $"Union case at route '{route}' (JSON token kind: {tokenKind}) should serialize to {expectedBody} but got {body}."); } [Fact] @@ -89,9 +109,9 @@ public async Task MapAction_ReturnsNullableUnionWrapper_HandlesValueAndNull() public async Task MapAction_ReturnsUnionWithNullableCase_HandlesEachCase() { var source = """ - app.MapGet("/int", () => new NullableCaseUnion((int?)5)); - app.MapGet("/null", () => new NullableCaseUnion((int?)null)); - app.MapGet("/string", () => new NullableCaseUnion("hi")); + app.MapGet("/int", () => new UnionNullableIntString((int?)5)); + app.MapGet("/null", () => new UnionNullableIntString((int?)null)); + app.MapGet("/string", () => new UnionNullableIntString("hi")); """; var (_, compilation) = await RunGeneratorAsync(source); var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); @@ -100,9 +120,10 @@ public async Task MapAction_ReturnsUnionWithNullableCase_HandlesEachCase() await endpoints.Single(e => e.RoutePattern.RawText == "/int").RequestDelegate(intCtx); await VerifyResponseBodyAsync(intCtx, "5"); - var nullCtx = CreateHttpContext(); - await endpoints.Single(e => e.RoutePattern.RawText == "/null").RequestDelegate(nullCtx); - await VerifyResponseBodyAsync(nullCtx, "null"); + // TODO enable after fix https://github.com/dotnet/runtime/issues/128688 + //var nullCtx = CreateHttpContext(); + //await endpoints.Single(e => e.RoutePattern.RawText == "/null").RequestDelegate(nullCtx); + //await VerifyResponseBodyAsync(nullCtx, "null"); var stringCtx = CreateHttpContext(); await endpoints.Single(e => e.RoutePattern.RawText == "/string").RequestDelegate(stringCtx); @@ -113,8 +134,8 @@ public async Task MapAction_ReturnsUnionWithNullableCase_HandlesEachCase() public async Task MapAction_ReturnsObjectCaseUnion_SerializesActiveCase() { var source = """ - app.MapGet("/cat", () => new Pet(new Cat("Whiskers"))); - app.MapGet("/dog", () => new Pet(new Dog("Labrador"))); + app.MapGet("/cat", () => new UnionPet(new Cat("Whiskers"))); + app.MapGet("/dog", () => new UnionPet(new Dog("Labrador"))); """; var (_, compilation) = await RunGeneratorAsync(source); var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); @@ -127,4 +148,99 @@ public async Task MapAction_ReturnsObjectCaseUnion_SerializesActiveCase() await endpoints.Single(e => e.RoutePattern.RawText == "/dog").RequestDelegate(dogCtx); await VerifyResponseBodyAsync(dogCtx, "{\"breed\":\"Labrador\"}"); } + + [Fact] + public async Task MapAction_ReturnsNestedUnion_SerializesInnermostCase() + { + var source = """ + app.MapGet("/int", () => new UnionOuter(new UnionInner(42))); + app.MapGet("/string", () => new UnionOuter(new UnionInner("nested"))); + app.MapGet("/bool", () => new UnionOuter(true)); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var intCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/int").RequestDelegate(intCtx); + await VerifyResponseBodyAsync(intCtx, "42"); + + var stringCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/string").RequestDelegate(stringCtx); + await VerifyResponseBodyAsync(stringCtx, "\"nested\""); + + var boolCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/bool").RequestDelegate(boolCtx); + await VerifyResponseBodyAsync(boolCtx, "true"); + } + + [Fact] + public async Task MapAction_ReturnsUnion_RuntimeTypeResolvesToNearestDeclaredCase() + { + // UnionPet declares only Cat and Dog as cases. + // /declared: returns a Dog (the exact declared case type) — serialized via Dog's contract. + // /derived: returns a SausageDog (derived from Dog) — STJ walks runtime type up to the + // nearest declared case (Dog) and serializes using Dog's contract. The + // SausageDog-only "Length" property is silently dropped. This mirrors + // [JsonPolymorphic] default behavior for unknown derived types. + var source = """ + app.MapGet("/declared", () => new UnionPet(new Dog("Labrador"))); + app.MapGet("/derived", () => new UnionPet(new SausageDog("Dachshund", 40.5))); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var declaredCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/declared").RequestDelegate(declaredCtx); + await VerifyResponseBodyAsync(declaredCtx, "{\"breed\":\"Labrador\"}"); + + var derivedCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/derived").RequestDelegate(derivedCtx); + // Length is dropped — Dog's contract has only "Breed". + await VerifyResponseBodyAsync(derivedCtx, "{\"breed\":\"Dachshund\"}"); + } + + // Ambiguous unions (multiple cases share a JSON token kind). + [Fact] + public async Task MapAction_ReturnsAmbiguousNumericUnion_SerializeWorksByDotNetType() + { + // UnionIntShort(int, short) — both cases map to JsonValueType.Number. + // The deconstructor sees the runtime .NET type and picks the correct case unambiguously, + // so the wire output is identical to the non-ambiguous case. + var source = """ + app.MapGet("/int", () => new UnionIntShort(42)); + app.MapGet("/short", () => new UnionIntShort((short)7)); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var intCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/int").RequestDelegate(intCtx); + await VerifyResponseBodyAsync(intCtx, "42"); + + var shortCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/short").RequestDelegate(shortCtx); + await VerifyResponseBodyAsync(shortCtx, "7"); + } + + [Fact] + public async Task MapAction_ReturnsAmbiguousStringUnion_SerializeWorksByDotNetType() + { + // UnionDateTimeString(DateTime, string) — both cases map to JsonValueType.String. + // Same as the numeric ambiguity case: serialize works because the deconstructor knows + // the .NET type. Deserialization would throw "String ambiguous" without a classifier. + var source = """ + app.MapGet("/datetime", () => new UnionDateTimeString(new DateTime(2024, 5, 28, 10, 0, 0, DateTimeKind.Unspecified))); + app.MapGet("/string", () => new UnionDateTimeString("hello")); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var dtCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/datetime").RequestDelegate(dtCtx); + await VerifyResponseBodyAsync(dtCtx, "\"2024-05-28T10:00:00\""); + + var stringCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/string").RequestDelegate(stringCtx); + await VerifyResponseBodyAsync(stringCtx, "\"hello\""); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs index 8bf475b503ff..01c5126cc3fe 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -4,12 +4,40 @@ namespace Http; -#nullable enable +#nullable enable +// By convention every union type in this file starts with "Union". + +// Simple unambiguous primitive-paired unions public union UnionIntString(int, string); +public union UnionByteString(byte, string); +public union UnionShortString(short, string); +public union UnionLongString(long, string); +public union UnionDecimalString(decimal, string); +public union UnionDoubleString(double, string); +public union UnionBoolString(bool, string); +public union UnionGuidInt(Guid, int); // String + Number +public union UnionDateTimeInt(DateTime, int); // String + Number +public union UnionCharInt(char, int); // String + Number (char serializes as JSON string) -public union NullableCaseUnion(int?, string); +// Nullable case +public union UnionNullableIntString(int?, string); +// Object-case union. public record Cat(string Name); public record Dog(string Breed); -public union Pet(Cat, Dog); \ No newline at end of file +public union UnionPet(Cat, Dog); + +// Derived type that is NOT a declared case of UnionPet — used to verify STJ resolves to +// the nearest declared ancestor (Dog) when handed a SausageDog. +public record SausageDog(string Breed, double Length) : Dog(Breed); + +// Nested-union scenarios. +public union UnionInner(int, string); +public union UnionOuter(UnionInner, bool); // union case is itself a union + +// Ambiguous unions +#pragma warning disable SYSLIB1227 +public union UnionIntShort(int, short); // both → Number +public union UnionDateTimeString(DateTime, string); // both → String +#pragma warning restore SYSLIB1227 From e03e4bd08717cb78709f1c90a8260b4082973aa3 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 29 May 2026 11:09:28 +0200 Subject: [PATCH 03/11] Results / TypedResults, property and $type discriminators --- .../RequestDelegateCreationTests.Unions.cs | 145 +++++++++++++++++- .../SharedTypes.Unions.cs | 37 ++++- 2 files changed, 177 insertions(+), 5 deletions(-) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs index d9b36118d52a..e46192fef3dd 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs @@ -133,6 +133,11 @@ public async Task MapAction_ReturnsUnionWithNullableCase_HandlesEachCase() [Fact] public async Task MapAction_ReturnsObjectCaseUnion_SerializesActiveCase() { + // UnionPet(Cat, Dog) — both cases share JsonValueType.Object, so this is also + // an "object-bucket ambiguous" union from STJ's perspective. Serialization is + // unambiguous because the deconstructor dispatches by .NET runtime type; the + // ambiguity only matters on the deserialize path (where a classifier or property- + // name dispatch is needed to pick the case). var source = """ app.MapGet("/cat", () => new UnionPet(new Cat("Whiskers"))); app.MapGet("/dog", () => new UnionPet(new Dog("Labrador"))); @@ -204,8 +209,7 @@ public async Task MapAction_ReturnsUnion_RuntimeTypeResolvesToNearestDeclaredCas public async Task MapAction_ReturnsAmbiguousNumericUnion_SerializeWorksByDotNetType() { // UnionIntShort(int, short) — both cases map to JsonValueType.Number. - // The deconstructor sees the runtime .NET type and picks the correct case unambiguously, - // so the wire output is identical to the non-ambiguous case. + var source = """ app.MapGet("/int", () => new UnionIntShort(42)); app.MapGet("/short", () => new UnionIntShort((short)7)); @@ -226,8 +230,7 @@ public async Task MapAction_ReturnsAmbiguousNumericUnion_SerializeWorksByDotNetT public async Task MapAction_ReturnsAmbiguousStringUnion_SerializeWorksByDotNetType() { // UnionDateTimeString(DateTime, string) — both cases map to JsonValueType.String. - // Same as the numeric ambiguity case: serialize works because the deconstructor knows - // the .NET type. Deserialization would throw "String ambiguous" without a classifier. + var source = """ app.MapGet("/datetime", () => new UnionDateTimeString(new DateTime(2024, 5, 28, 10, 0, 0, DateTimeKind.Unspecified))); app.MapGet("/string", () => new UnionDateTimeString("hello")); @@ -243,4 +246,138 @@ public async Task MapAction_ReturnsAmbiguousStringUnion_SerializeWorksByDotNetTy await endpoints.Single(e => e.RoutePattern.RawText == "/string").RequestDelegate(stringCtx); await VerifyResponseBodyAsync(stringCtx, "\"hello\""); } + + [Fact] + public async Task MapAction_ReturnsUnionViaResultWrappers_SerializesActiveCase() + { + var source = """ + app.MapGet("/results-ok-int", () => Results.Ok(new UnionIntString(42))); + app.MapGet("/results-ok-string", () => Results.Ok(new UnionIntString("hi"))); + app.MapGet("/typed-ok-int", () => TypedResults.Ok(new UnionIntString(42))); + app.MapGet("/typed-ok-string", () => TypedResults.Ok(new UnionIntString("hi"))); + app.MapGet("/object-int", object () => new UnionIntString(42)); + app.MapGet("/object-string", object () => new UnionIntString("hi")); + app.MapGet("/iresult-task", Task () => Task.FromResult(TypedResults.Ok(new UnionIntString(42)))); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var cases = new (string Route, string Expected)[] + { + ("/results-ok-int", "42"), + ("/results-ok-string", "\"hi\""), + ("/typed-ok-int", "42"), + ("/typed-ok-string", "\"hi\""), + ("/object-int", "42"), + ("/object-string", "\"hi\""), + ("/iresult-task", "42"), + }; + + foreach (var (route, expected) in cases) + { + var ctx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == route).RequestDelegate(ctx); + var body = await GetResponseBodyAsync(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + Assert.True(body == expected, $"Route '{route}' should serialize wrapped union to {expected} but got {body}."); + } + } + + [Fact] + public async Task MapAction_ReturnsUnionInContainer_SerializesActiveCase() + { + // Testing a union flowing through other STJ containers (object property, array, dictionary value) + + var source = """ + app.MapGet("/property-int", () => new UnionEnvelope("abc", new UnionIntString(42))); + app.MapGet("/property-string", () => new UnionEnvelope("abc", new UnionIntString("hi"))); + app.MapGet("/array", () => new[] { new UnionIntString(1), new UnionIntString("two") }); + app.MapGet("/dictionary", () => new Dictionary + { + ["a"] = new UnionIntString(1), + ["b"] = new UnionIntString("two"), + }); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var cases = new (string Route, string Expected)[] + { + ("/property-int", "{\"correlationId\":\"abc\",\"payload\":42}"), + ("/property-string", "{\"correlationId\":\"abc\",\"payload\":\"hi\"}"), + ("/array", "[1,\"two\"]"), + ("/dictionary", "{\"a\":1,\"b\":\"two\"}"), + }; + + foreach (var (route, expected) in cases) + { + var ctx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == route).RequestDelegate(ctx); + var body = await GetResponseBodyAsync(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + Assert.True(body == expected, $"Route '{route}' (union in container) should serialize to {expected} but got {body}."); + } + } + + [Fact] + public async Task MapAction_ReturnsUnion_WithPolymorphicCaseType_EmitsDiscriminator() + { + // UnionAnimalString(PolyAnimal, string) has a polymorphic case type (PolyAnimal carries + // [JsonPolymorphic] + [JsonDerivedType] for PolyCat/PolyDog). "$type" discriminator should be emitted. + + // However UnionPolyCatDog(PolyCat, PolyDog) declares the *concrete derived types* as cases. + // STJ uses PolyCat's own contract — which has no polymorphism options of its own — so NO discriminator is emitted. + + var source = """ + app.MapGet("/cat", () => new UnionAnimalString(new PolyCat("Whiskers"))); + app.MapGet("/dog", () => new UnionAnimalString(new PolyDog("Labrador"))); + app.MapGet("/string", () => new UnionAnimalString("plain")); + app.MapGet("/concrete-cat", () => new UnionPolyCatDog(new PolyCat("Whiskers"))); + app.MapGet("/concrete-dog", () => new UnionPolyCatDog(new PolyDog("Labrador"))); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var cases = new (string Route, string Expected)[] + { + ("/cat", """{"$type":"cat","name":"Whiskers"}"""), + ("/dog", """{"$type":"dog","breed":"Labrador"}"""), + ("/string", "\"plain\""), + // No "$type" — union case type is the concrete derived type, not the polymorphic base. + ("/concrete-cat", "{\"name\":\"Whiskers\"}"), + ("/concrete-dog", "{\"breed\":\"Labrador\"}"), + }; + + foreach (var (route, expected) in cases) + { + var ctx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == route).RequestDelegate(ctx); + var body = await GetResponseBodyAsync(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + Assert.True(body == expected, $"Route '{route}' (polymorphic union case) should serialize to {expected} but got {body}."); + } + } + + [Fact] + public async Task MapAction_ReturnsUnion_WithCustomTypeClassifier_SerializationUnaffected() + { + // A user-supplied JsonTypeClassifier only affects deserialization. + // Applying [JsonUnion(TypeClassifier = ...)] to an ambiguous union must not change the serialized output, + // which is still dispatched by .NET runtime type via the deconstructor. + + var source = """ + app.MapGet("/int", () => new UnionIntShortWithClassifier(42)); + app.MapGet("/short", () => new UnionIntShortWithClassifier((short)7)); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var intCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/int").RequestDelegate(intCtx); + await VerifyResponseBodyAsync(intCtx, "42"); + + var shortCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/short").RequestDelegate(shortCtx); + await VerifyResponseBodyAsync(shortCtx, "7"); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs index 01c5126cc3fe..2b1e6ce8c9b1 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Http; @@ -23,7 +25,10 @@ namespace Http; // Nullable case public union UnionNullableIntString(int?, string); -// Object-case union. +// Object-case union. Both cases share the JSON Object value kind, which makes UnionPet +// implicitly ambiguous on the deserialize side (resolved here via property-name dispatch +// when the user supplies a classifier). For serialization the deconstructor dispatches by +// .NET runtime type, so no classifier is needed. public record Cat(string Name); public record Dog(string Breed); public union UnionPet(Cat, Dog); @@ -40,4 +45,34 @@ public record SausageDog(string Breed, double Length) : Dog(Breed); #pragma warning disable SYSLIB1227 public union UnionIntShort(int, short); // both → Number public union UnionDateTimeString(DateTime, string); // both → String +public union UnionPolyCatDog(PolyCat, PolyDog); // both → Object, declared as the concrete derived types #pragma warning restore SYSLIB1227 + +// Envelope: union used as a property of another model. +public record UnionEnvelope(string CorrelationId, UnionIntString Payload); + +// Polymorphism on a union case type: PolyAnimal is a JSON-polymorphic base with two +// derived types. When used as a union case, returning a derived instance should emit +// the "$type" discriminator from the polymorphic contract. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(PolyCat), "cat")] +[JsonDerivedType(typeof(PolyDog), "dog")] +public record PolyAnimal(); +public record PolyCat(string Name) : PolyAnimal(); +public record PolyDog(string Breed) : PolyAnimal(); +public union UnionAnimalString(PolyAnimal, string); + +// Ambiguous numeric union with a user-supplied classifier. The classifier only affects +// deserialization; serialization should be identical to UnionIntShort. Verifies the +// JsonUnionAttribute is wired through the metadata pipeline without breaking writes. +#pragma warning disable SYSLIB1227 +[JsonUnion(TypeClassifier = typeof(IntFirstClassifierFactory))] +public union UnionIntShortWithClassifier(int, short); +#pragma warning restore SYSLIB1227 + +// Trivial classifier: always pick int. (Serialize tests don't exercise this path.) +public sealed class IntFirstClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => typeof(int); +} From fd3ec8c37fa627ee94c8156455e8731438c242ec Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 29 May 2026 11:29:29 +0200 Subject: [PATCH 04/11] IAsyncEnumerable + configurehttpjsonoptions --- .../RequestDelegateCreationTests.Unions.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs index e46192fef3dd..441e149ba72c 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Http.Generators.Tests; @@ -380,4 +381,59 @@ public async Task MapAction_ReturnsUnion_WithCustomTypeClassifier_SerializationU await endpoints.Single(e => e.RoutePattern.RawText == "/short").RequestDelegate(shortCtx); await VerifyResponseBodyAsync(shortCtx, "7"); } + + [Fact] + public async Task MapAction_ReturnsAsyncEnumerableOfUnion_StreamsActiveCasePerItem() + { + // Streaming endpoints: IAsyncEnumerable + + var source = """ + app.MapGet("/stream", () => GetUnionsAsync()); + + static async IAsyncEnumerable GetUnionsAsync() + { + yield return new UnionIntString(1); + await Task.Yield(); + yield return new UnionIntString("two"); + yield return new UnionIntString(3); + } + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation).OfType().Single(e => e.RoutePattern.RawText == "/stream"); + + var ctx = CreateHttpContext(); + await endpoint.RequestDelegate(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + await VerifyResponseBodyAsync(ctx, "[1,\"two\",3]"); + } + + [Fact] + public async Task MapAction_ReturnsUnion_HonorsConfigureHttpJsonOptions() + { + // ConfigureHttpJsonOptions modifications (naming policy, indented output, etc.) + + var source = """ + app.MapGet("/envelope", () => new UnionEnvelope("abc", new UnionIntString(42))); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(services => + { + services.ConfigureHttpJsonOptions(o => + { + o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + o.SerializerOptions.WriteIndented = true; + }); + }); + var endpoint = GetEndpointsFromCompilation(compilation, serviceProvider: serviceProvider).OfType().Single(e => e.RoutePattern.RawText == "/envelope"); + + var ctx = CreateHttpContext(serviceProvider); + await endpoint.RequestDelegate(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var body = await GetResponseBodyAsync(ctx); + + // Two assertions instead of a brittle exact-match: snake_case applied AND indentation produced newlines. + Assert.Contains("\"correlation_id\": \"abc\"", body); + Assert.Contains("\"payload\": 42", body); + Assert.Contains("\n", body); + } } From ff6f826c42f81d0ba5b3d7bd078a7ade2128bd5b Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 29 May 2026 13:02:10 +0200 Subject: [PATCH 05/11] body --- ...equestDelegateCreationTests.Unions.Body.cs | 128 ++++++++++++++++++ ...DelegateCreationTests.Unions.Responses.cs} | 0 .../SharedTypes.Unions.cs | 119 ++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs rename src/Http/Http.Extensions/test/RequestDelegateGenerator/{RequestDelegateCreationTests.Unions.cs => RequestDelegateCreationTests.Unions.Responses.cs} (100%) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs new file mode 100644 index 000000000000..dd8625c47b40 --- /dev/null +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Http.Generators.Tests; + +public abstract partial class RequestDelegateCreationTests : RequestDelegateCreationTestBase +{ + [Theory] + [InlineData("true", "true", "Boolean")] + [InlineData("false", "false", "Boolean")] + [InlineData("\"hi\"", "\"hi\"", "String")] + public async Task MapAction_UnionBody_NaturallyUnambiguousPrimitiveCases_RoundTrip( + string requestJson, string expectedBody, string tokenKindForReadability) + { + var source = """ + app.MapPost("/bool-string", (UnionBoolString u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation) + .OfType() + .Single(e => e.RoutePattern.RawText == "/bool-string"); + + var ctx = CreateHttpContextWithJson(requestJson); + await endpoint.RequestDelegate(ctx); + + Assert.True(ctx.Response.StatusCode == 200, $"UnionBoolString body bind for {tokenKindForReadability} payload {requestJson} should return 200 but got {ctx.Response.StatusCode}."); + await VerifyResponseBodyAsync(ctx, expectedBody); + } + + [Theory] + [InlineData("/byte-string", "\"hi\"")] + [InlineData("/short-string", "\"hi\"")] + [InlineData("/int-string", "\"hi\"")] + [InlineData("/long-string", "\"hi\"")] + [InlineData("/decimal-string", "\"hi\"")] + [InlineData("/double-string", "\"hi\"")] + [InlineData("/guid-int", "\"00000000-0000-0000-0000-000000000001\"")] + [InlineData("/datetime-int", "\"2024-05-28T10:00:00\"")] + [InlineData("/char-int", "\"x\"")] + public async Task MapAction_UnionBody_AmbiguousPrimitiveCases_WithoutClassifier_ReturnsBadRequestUnderWebDefaults( + string route, string requestJson) + { + // Every (numeric, string) and (string-shaped, numeric) primitive union is silently ambiguous on the JSON String token + // because NumberHandling.AllowReadingFromString makes the numeric case String-eligible. + // Sending a String-token payload throws JsonException at deserialize time and + // surfaces as HTTP 400 with EventId "InvalidJsonRequestBody". + + var source = """ + app.MapPost("/byte-string", (UnionByteString u) => u); + app.MapPost("/short-string", (UnionShortString u) => u); + app.MapPost("/int-string", (UnionIntString u) => u); + app.MapPost("/long-string", (UnionLongString u) => u); + app.MapPost("/decimal-string", (UnionDecimalString u) => u); + app.MapPost("/double-string", (UnionDoubleString u) => u); + app.MapPost("/guid-int", (UnionGuidInt u) => u); + app.MapPost("/datetime-int", (UnionDateTimeInt u) => u); + app.MapPost("/char-int", (UnionCharInt u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation) + .OfType() + .Single(e => e.RoutePattern.RawText == route); + + var ctx = CreateHttpContextWithJson(requestJson); + await endpoint.RequestDelegate(ctx); + + Assert.Equal(400, ctx.Response.StatusCode); + var log = Assert.Single(TestSink.Writes, w => w.EventId.Name == "InvalidJsonRequestBody"); + var jsonException = Assert.IsType(log.Exception); + Assert.Contains("ambiguous", jsonException.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("union type", jsonException.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + // Numeric (Number-token) cases. + [InlineData("/byte-string", "42", "42")] + [InlineData("/short-string", "1234", "1234")] + [InlineData("/int-string", "42", "42")] + [InlineData("/long-string", "9999999999", "9999999999")] + [InlineData("/decimal-string", "3.14", "3.14")] + [InlineData("/double-string", "2.5", "2.5")] + [InlineData("/guid-int", "42", "42")] + [InlineData("/datetime-int", "42", "42")] + [InlineData("/char-int", "42", "42")] + // String-shaped (String-token) cases. + [InlineData("/byte-string", "\"hi\"", "\"hi\"")] + [InlineData("/short-string", "\"hi\"", "\"hi\"")] + [InlineData("/int-string", "\"hi\"", "\"hi\"")] + [InlineData("/long-string", "\"hi\"", "\"hi\"")] + [InlineData("/decimal-string", "\"hi\"", "\"hi\"")] + [InlineData("/double-string", "\"hi\"", "\"hi\"")] + [InlineData("/guid-int", "\"00000000-0000-0000-0000-000000000001\"", "\"00000000-0000-0000-0000-000000000001\"")] + [InlineData("/datetime-int", "\"2024-05-28T10:00:00\"", "\"2024-05-28T10:00:00\"")] + [InlineData("/char-int", "\"x\"", "\"x\"")] + public async Task MapAction_UnionBody_AmbiguousPrimitiveCasesWithClassifier_RoundTrip( + string route, string requestJson, string expectedBody) + { + // Same primitive-pair unions as MapAction_UnionBody_AmbiguousPrimitiveCases_WithoutClassifier_ReturnsBadRequestUnderWebDefaults, + // but each declares a classifier that resolves the String-token ambiguity by dispatching on token shape + // (Number -> numeric case, String -> string-shaped case). + + var source = """ + app.MapPost("/byte-string", (UnionByteStringWithClassifier u) => u); + app.MapPost("/short-string", (UnionShortStringWithClassifier u) => u); + app.MapPost("/int-string", (UnionIntStringWithClassifier u) => u); + app.MapPost("/long-string", (UnionLongStringWithClassifier u) => u); + app.MapPost("/decimal-string", (UnionDecimalStringWithClassifier u) => u); + app.MapPost("/double-string", (UnionDoubleStringWithClassifier u) => u); + app.MapPost("/guid-int", (UnionGuidIntWithClassifier u) => u); + app.MapPost("/datetime-int", (UnionDateTimeIntWithClassifier u) => u); + app.MapPost("/char-int", (UnionCharIntWithClassifier u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation) + .OfType() + .Single(e => e.RoutePattern.RawText == route); + + var ctx = CreateHttpContextWithJson(requestJson); + await endpoint.RequestDelegate(ctx); + + Assert.True(ctx.Response.StatusCode == 200, + $"Classifier-resolved union at '{route}' with payload {requestJson} should return 200 but got {ctx.Response.StatusCode}."); + await VerifyResponseBodyAsync(ctx, expectedBody); + } +} diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Responses.cs similarity index 100% rename from src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.cs rename to src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Responses.cs diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs index 2b1e6ce8c9b1..d073409ac9c0 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -76,3 +76,122 @@ public sealed class IntFirstClassifierFactory : JsonTypeClassifierFactory static (ref Utf8JsonReader reader) => typeof(int); } + +// Primitive-case unions paired with a classifier that disambiguates the cases + +[JsonUnion(TypeClassifier = typeof(UnionByteStringClassifierFactory))] +public union UnionByteStringWithClassifier(byte, string); +public sealed class UnionByteStringClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(byte), + JsonTokenType.String => typeof(string), + _ => null, + }; +} + +[JsonUnion(TypeClassifier = typeof(UnionShortStringClassifierFactory))] +public union UnionShortStringWithClassifier(short, string); +public sealed class UnionShortStringClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(short), + JsonTokenType.String => typeof(string), + _ => null, + }; +} + +[JsonUnion(TypeClassifier = typeof(UnionIntStringClassifierFactory))] +public union UnionIntStringWithClassifier(int, string); +public sealed class UnionIntStringClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(int), + JsonTokenType.String => typeof(string), + _ => null, + }; +} + +[JsonUnion(TypeClassifier = typeof(UnionLongStringClassifierFactory))] +public union UnionLongStringWithClassifier(long, string); +public sealed class UnionLongStringClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(long), + JsonTokenType.String => typeof(string), + _ => null, + }; +} + +[JsonUnion(TypeClassifier = typeof(UnionDecimalStringClassifierFactory))] +public union UnionDecimalStringWithClassifier(decimal, string); +public sealed class UnionDecimalStringClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(decimal), + JsonTokenType.String => typeof(string), + _ => null, + }; +} + +[JsonUnion(TypeClassifier = typeof(UnionDoubleStringClassifierFactory))] +public union UnionDoubleStringWithClassifier(double, string); +public sealed class UnionDoubleStringClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(double), + JsonTokenType.String => typeof(string), + _ => null, + }; +} + +[JsonUnion(TypeClassifier = typeof(UnionGuidIntClassifierFactory))] +public union UnionGuidIntWithClassifier(Guid, int); +public sealed class UnionGuidIntClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(int), + JsonTokenType.String => typeof(Guid), + _ => null, + }; +} + +[JsonUnion(TypeClassifier = typeof(UnionDateTimeIntClassifierFactory))] +public union UnionDateTimeIntWithClassifier(DateTime, int); +public sealed class UnionDateTimeIntClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(int), + JsonTokenType.String => typeof(DateTime), + _ => null, + }; +} + +[JsonUnion(TypeClassifier = typeof(UnionCharIntClassifierFactory))] +public union UnionCharIntWithClassifier(char, int); +public sealed class UnionCharIntClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(int), + JsonTokenType.String => typeof(char), + _ => null, + }; +} From db9a82d9b1913a7bce0d22e156a17e6bf138109e Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 29 May 2026 13:59:52 +0200 Subject: [PATCH 06/11] body tests --- ...equestDelegateCreationTests.Unions.Body.cs | 317 +++++++++++++++++- .../SharedTypes.Unions.cs | 98 +++++- 2 files changed, 412 insertions(+), 3 deletions(-) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs index dd8625c47b40..bfdfd6dea0b7 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Http.Generators.Tests; @@ -121,8 +122,320 @@ public async Task MapAction_UnionBody_AmbiguousPrimitiveCasesWithClassifier_Roun var ctx = CreateHttpContextWithJson(requestJson); await endpoint.RequestDelegate(ctx); - Assert.True(ctx.Response.StatusCode == 200, - $"Classifier-resolved union at '{route}' with payload {requestJson} should return 200 but got {ctx.Response.StatusCode}."); + Assert.True(ctx.Response.StatusCode == 200, $"Classifier-resolved union at '{route}' with payload {requestJson} should return 200 but got {ctx.Response.StatusCode}."); await VerifyResponseBodyAsync(ctx, expectedBody); } + + [Fact] + public async Task MapAction_UnionBody_NullableUnionWrapper_HandlesValueAndNull() + { + // Body analog of MapAction_ReturnsNullableUnionWrapper_HandlesValueAndNull: + // a UnionIntString? body parameter accepts both a concrete value and a JSON null payload. + + var source = """ + app.MapPost("/maybe", (UnionIntString? u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation).OfType() + .Single(e => e.RoutePattern.RawText == "/maybe"); + + var valueCtx = CreateHttpContextWithJson("42"); + await endpoint.RequestDelegate(valueCtx); + Assert.Equal(200, valueCtx.Response.StatusCode); + await VerifyResponseBodyAsync(valueCtx, "42"); + + var nullCtx = CreateHttpContextWithJson("null"); + await endpoint.RequestDelegate(nullCtx); + Assert.Equal(200, nullCtx.Response.StatusCode); + await VerifyResponseBodyAsync(nullCtx, "null"); + } + + [Fact] + public async Task MapAction_UnionBody_UnionWithNullableCase_FailsWithoutClassifier_PassesWithClassifier() + { + // UnionNullableIntString(int?, string): + // Number → only int? matches → unambiguous (works on both endpoints) + // String → ambiguous (NumberHandling makes int? string-eligible) + // Null → ambiguous (both cases accept null) + // The classifier dispatches Number/Null → int and String → string. + + var source = """ + app.MapPost("/nullable-int-string", (UnionNullableIntString u) => u); + app.MapPost("/nullable-int-string-classifier", (UnionNullableIntStringWithClassifier u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + // Unambiguous Number token works on the plain endpoint. + var okCtx = CreateHttpContextWithJson("42"); + await endpoints.Single(e => e.RoutePattern.RawText == "/nullable-int-string").RequestDelegate(okCtx); + Assert.Equal(200, okCtx.Response.StatusCode); + await VerifyResponseBodyAsync(okCtx, "42"); + + // Ambiguous String token + var stringCtx = CreateHttpContextWithJson("\"hi\""); + await endpoints.Single(e => e.RoutePattern.RawText == "/nullable-int-string").RequestDelegate(stringCtx); + Assert.True(stringCtx.Response.StatusCode == 400, $"UnionNullableIntString body bind for {"\"hi\""} should return 400 but got {stringCtx.Response.StatusCode}."); + + // TODO enable after fix https://github.com/dotnet/runtime/issues/128688 + var nullCtx = CreateHttpContextWithJson("null"); + await endpoints.Single(e => e.RoutePattern.RawText == "/nullable-int-string").RequestDelegate(stringCtx); + Assert.True(stringCtx.Response.StatusCode == 400, $"UnionNullableIntString body bind for \"null\" should return 400 but got {stringCtx.Response.StatusCode}."); + + // Classifier endpoint accepts and round-trips every token kind. + foreach (var (payload, expected) in new[] + { + ("42", "42"), + ("\"hi\"", "\"hi\""), + // TODO enable after fix https://github.com/dotnet/runtime/issues/128688 + // ("null", "null"), + }) + { + var passCtx = CreateHttpContextWithJson(payload); + await endpoints.Single(e => e.RoutePattern.RawText == "/nullable-int-string-classifier").RequestDelegate(passCtx); + Assert.True(passCtx.Response.StatusCode == 200, $"Classifier-resolved UnionNullableIntString for {payload} should return 200 but got {passCtx.Response.StatusCode}."); + await VerifyResponseBodyAsync(passCtx, expected); + } + } + + [Fact] + public async Task MapAction_UnionBody_ObjectCaseUnion_FailsWithoutClassifier_PassesWithClassifier() + { + // UnionPet(Cat, Dog): both cases serialize to JSON objects, so the Object token is ambiguous. + // The classifier resolves by property-name dispatch: "name" → Cat, "breed" → Dog. + + var source = """ + app.MapPost("/pet", (UnionPet u) => u); + app.MapPost("/pet-classifier", (UnionPetWithClassifier u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + foreach (var payload in new[] + { + "{\"name\":\"Whiskers\"}", + "{\"breed\":\"Labrador\"}", + }) + { + var failCtx = CreateHttpContextWithJson(payload); + await endpoints.Single(e => e.RoutePattern.RawText == "/pet").RequestDelegate(failCtx); + Assert.True(failCtx.Response.StatusCode == 400, $"UnionPet body bind for {payload} should return 400 but got {failCtx.Response.StatusCode}."); + } + + foreach (var (payload, expected) in new[] + { + ("{\"name\":\"Whiskers\"}", "{\"name\":\"Whiskers\"}"), + ("{\"breed\":\"Labrador\"}", "{\"breed\":\"Labrador\"}"), + }) + { + var passCtx = CreateHttpContextWithJson(payload); + await endpoints.Single(e => e.RoutePattern.RawText == "/pet-classifier").RequestDelegate(passCtx); + Assert.True(passCtx.Response.StatusCode == 200, $"UnionPetWithClassifier body bind for {payload} should return 200 but got {passCtx.Response.StatusCode}."); + await VerifyResponseBodyAsync(passCtx, expected); + } + } + + [Fact] + public async Task MapAction_UnionBody_ObjectCaseUnion_PayloadOfDerivedType_ResolvesToNearestDeclaredCase() + { + // UnionPet only declares Cat and Dog. A SausageDog-shaped payload (has "breed" and "length") + // is dispatched by the classifier to Dog (it matches on "breed" first). STJ then deserializes + // through Dog's contract — the SausageDog-only "Length" property is silently dropped. + // Mirrors the runtime-type behavior tested in MapAction_ReturnsUnion_RuntimeTypeResolvesToNearestDeclaredCase. + + var source = """ + app.MapPost("/pet-classifier", (UnionPetWithClassifier u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation).OfType() + .Single(e => e.RoutePattern.RawText == "/pet-classifier"); + + var ctx = CreateHttpContextWithJson("{\"breed\":\"Dachshund\",\"length\":40.5}"); + await endpoint.RequestDelegate(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + await VerifyResponseBodyAsync(ctx, "{\"breed\":\"Dachshund\"}"); + } + + [Fact] + public async Task MapAction_UnionBody_NestedUnion_RequiresOuterClassifierToReachInner() + { + // UnionOuter(UnionInner, bool): + // STJ's union deserializer does NOT recurse into nested-union cases when dispatching by + // JSON token type. It only knows how to map primitive token types to primitive case types + // — a nested union case like UnionInner has no associated JsonValueType, so the outer + // converter rejects every token that would have to flow into the inner union. + // + // Boolean → bool (token-distinct, no inner involvement) → 200 round-trip + // Number → would need UnionInner, but the outer can't dispatch into it → 400 + // String → same as Number → 400 + // + // Adding a classifier ONLY to the inner union (UnionOuterWithClassifier) does not help, + // because the outer converter never reaches the inner classifier. + // + // Adding a classifier at BOTH levels (UnionOuterWithBothClassifiers) makes the nested + // union fully reachable: the outer classifier dispatches Number/String → UnionInner type, + // and the inner classifier then resolves the Number/String ambiguity. + + var source = """ + app.MapPost("/outer", (UnionOuter u) => u); + app.MapPost("/outer-classifier", (UnionOuterWithClassifier u) => u); + app.MapPost("/outer-both-classifiers", (UnionOuterWithBothClassifiers u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + // Boolean: outer dispatches directly to the bool case. Works on every endpoint. + foreach (var route in new[] { "/outer", "/outer-classifier", "/outer-both-classifiers" }) + { + var boolCtx = CreateHttpContextWithJson("true"); + await endpoints.Single(e => e.RoutePattern.RawText == route).RequestDelegate(boolCtx); + Assert.Equal(200, boolCtx.Response.StatusCode); + await VerifyResponseBodyAsync(boolCtx, "true"); + } + + // Number and String require recursion into the inner union → 400 when no outer classifier + // routes through. The inner classifier on /outer-classifier never gets a chance to run. + foreach (var route in new[] { "/outer", "/outer-classifier" }) + { + foreach (var payload in new[] { "42", "\"hi\"" }) + { + var failCtx = CreateHttpContextWithJson(payload); + await endpoints.Single(e => e.RoutePattern.RawText == route).RequestDelegate(failCtx); + Assert.True(failCtx.Response.StatusCode == 400, $"Nested-union body bind for {payload} on {route} should return 400 but got {failCtx.Response.StatusCode}."); + } + } + + // With classifiers on BOTH the outer and inner unions, Number and String round-trip + // end-to-end through the nested case. + foreach (var (payload, expected) in new[] + { + ("42", "42"), + ("\"hi\"", "\"hi\""), + }) + { + var passCtx = CreateHttpContextWithJson(payload); + await endpoints.Single(e => e.RoutePattern.RawText == "/outer-both-classifiers").RequestDelegate(passCtx); + Assert.True(passCtx.Response.StatusCode == 200, $"Outer+inner classifier nested-union body bind for {payload} should return 200 but got {passCtx.Response.StatusCode}."); + await VerifyResponseBodyAsync(passCtx, expected); + } + } + + [Fact] + public async Task MapAction_UnionBody_AmbiguousNumericUnion_FailsWithoutClassifier_PassesWithClassifier() + { + // UnionIntShort(int, short): both cases share JsonValueType.Number. + // UnionIntShortWithClassifier uses IntFirstClassifierFactory which always returns typeof(int), + // resolving the Number-token ambiguity to the int case unconditionally. + + var source = """ + app.MapPost("/intshort", (UnionIntShort u) => u); + app.MapPost("/intshort-classifier", (UnionIntShortWithClassifier u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + var failCtx = CreateHttpContextWithJson("42"); + await endpoints.Single(e => e.RoutePattern.RawText == "/intshort").RequestDelegate(failCtx); + Assert.True(failCtx.Response.StatusCode == 400, $"UnionIntShort body bind for Number payload should return 400 but got {failCtx.Response.StatusCode}."); + + var passCtx = CreateHttpContextWithJson("42"); + await endpoints.Single(e => e.RoutePattern.RawText == "/intshort-classifier").RequestDelegate(passCtx); + Assert.Equal(200, passCtx.Response.StatusCode); + await VerifyResponseBodyAsync(passCtx, "42"); + } + + [Fact] + public async Task MapAction_UnionBody_UnionInContainer_FailsWithoutClassifier_PassesWithClassifier() + { + // UnionEnvelope(string CorrelationId, UnionIntString Payload): the envelope itself is unambiguous; + // the inner union ambiguity surfaces only when the Payload value is a String-token (ambiguous via NumberHandling). + // The WithClassifier envelope wraps UnionIntStringWithClassifier instead, resolving the inner ambiguity. + + var source = """ + app.MapPost("/envelope", (UnionEnvelope u) => u); + app.MapPost("/envelope-classifier", (UnionEnvelopeWithClassifier u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToArray(); + + // Number-token Payload: no inner ambiguity → both endpoints round-trip. + foreach (var route in new[] { "/envelope", "/envelope-classifier" }) + { + var ctx = CreateHttpContextWithJson("{\"correlationId\":\"abc\",\"payload\":42}"); + await endpoints.Single(e => e.RoutePattern.RawText == route).RequestDelegate(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + await VerifyResponseBodyAsync(ctx, "{\"correlationId\":\"abc\",\"payload\":42}"); + } + + // String-token Payload: inner UnionIntString ambiguous → 400 on the plain envelope. + var failCtx = CreateHttpContextWithJson("{\"correlationId\":\"abc\",\"payload\":\"hi\"}"); + await endpoints.Single(e => e.RoutePattern.RawText == "/envelope").RequestDelegate(failCtx); + Assert.True(failCtx.Response.StatusCode == 400, $"UnionEnvelope body bind for String Payload should return 400 but got {failCtx.Response.StatusCode}."); + + // Resolves string via the classifier. + var passCtx = CreateHttpContextWithJson("{\"correlationId\":\"abc\",\"payload\":\"hi\"}"); + await endpoints.Single(e => e.RoutePattern.RawText == "/envelope-classifier").RequestDelegate(passCtx); + Assert.Equal(200, passCtx.Response.StatusCode); + await VerifyResponseBodyAsync(passCtx, "{\"correlationId\":\"abc\",\"payload\":\"hi\"}"); + } + + [Fact] + public async Task MapAction_UnionBody_PolymorphicCaseUnion_BindsActiveCase() + { + // UnionAnimalString(PolyAnimal, string): Object (PolyAnimal) vs String (string) → token-distinct. + // PolyAnimal carries [JsonPolymorphic] + [JsonDerivedType] so the concrete derived case (PolyCat/PolyDog) + // is discovered via the "$type" discriminator on the deserialize path. + + var source = """ + app.MapPost("/animal", (UnionAnimalString u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation).OfType() + .Single(e => e.RoutePattern.RawText == "/animal"); + + foreach (var (payload, expected) in new[] + { + ("{\"$type\":\"cat\",\"name\":\"Whiskers\"}", "{\"$type\":\"cat\",\"name\":\"Whiskers\"}"), + ("{\"$type\":\"dog\",\"breed\":\"Labrador\"}", "{\"$type\":\"dog\",\"breed\":\"Labrador\"}"), + ("\"plain\"", "\"plain\""), + }) + { + var ctx = CreateHttpContextWithJson(payload); + await endpoint.RequestDelegate(ctx); + Assert.True(ctx.Response.StatusCode == 200, $"UnionAnimalString body bind for {payload} should return 200 but got {ctx.Response.StatusCode}."); + await VerifyResponseBodyAsync(ctx, expected); + } + } + + [Fact] + public async Task MapAction_UnionBody_HonorsConfigureHttpJsonOptions() + { + // ConfigureHttpJsonOptions (snake_case naming policy + WriteIndented) flows through the body + // binding path and is honored on both the deserialize and the serialize side. + + var source = """ + app.MapPost("/envelope", (UnionEnvelope u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(services => + { + services.ConfigureHttpJsonOptions(o => + { + o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + o.SerializerOptions.WriteIndented = true; + }); + }); + var endpoint = GetEndpointsFromCompilation(compilation, serviceProvider: serviceProvider) + .OfType().Single(e => e.RoutePattern.RawText == "/envelope"); + + var ctx = CreateHttpContextWithJson("{\"correlation_id\":\"abc\",\"payload\":42}", serviceProvider); + await endpoint.RequestDelegate(ctx); + Assert.Equal(200, ctx.Response.StatusCode); + var body = await GetResponseBodyAsync(ctx); + + // Two assertions instead of a brittle exact-match: snake_case applied AND indentation produced newlines. + Assert.Contains("\"correlation_id\": \"abc\"", body); + Assert.Contains("\"payload\": 42", body); + Assert.Contains("\n", body); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs index d073409ac9c0..93fe98b850ac 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -70,7 +70,7 @@ public record PolyDog(string Breed) : PolyAnimal(); public union UnionIntShortWithClassifier(int, short); #pragma warning restore SYSLIB1227 -// Trivial classifier: always pick int. (Serialize tests don't exercise this path.) +// Trivial classifier: always pick int public sealed class IntFirstClassifierFactory : JsonTypeClassifierFactory { public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => @@ -195,3 +195,99 @@ public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContex _ => null, }; } + +// Nullable-case union with classifier. Without a classifier this is ambiguous on both the +// String token (NumberHandling lets int? read from string) and the Null token (both cases +// accept null). The classifier returns the underlying primitive type (typeof(int)) — STJ +// stores the int? case using its underlying type, so typeof(int?) would be rejected with +// "runtime type is not supported by union type". +[JsonUnion(TypeClassifier = typeof(UnionNullableIntStringClassifierFactory))] +public union UnionNullableIntStringWithClassifier(int?, string); +public sealed class UnionNullableIntStringClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(int), + JsonTokenType.String => typeof(string), + JsonTokenType.Null => typeof(int), + _ => null, + }; +} + +// Object-case union resolved by property-name dispatch. Cat has "name", Dog has "breed". +// The classifier clones the reader, walks the first object members until it finds a property +// that identifies the case, and returns the matching type. Property comparison is case-insensitive +// across the standard policies (PascalCase declaration + camelCase from web defaults). +[JsonUnion(TypeClassifier = typeof(UnionPetClassifierFactory))] +public union UnionPetWithClassifier(Cat, Dog); +public sealed class UnionPetClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => + { + if (reader.TokenType != JsonTokenType.StartObject) + { + return null; + } + + var clone = reader; + clone.Read(); + while (clone.TokenType == JsonTokenType.PropertyName) + { + if (clone.ValueTextEquals("name") || clone.ValueTextEquals("Name")) + { + return typeof(Cat); + } + if (clone.ValueTextEquals("breed") || clone.ValueTextEquals("Breed")) + { + return typeof(Dog); + } + + clone.Read(); + clone.Skip(); + clone.Read(); + } + + return null; + }; +} + +// Inner union with a classifier — used as a case type of a nested outer union below. +[JsonUnion(TypeClassifier = typeof(UnionInnerWithClassifierFactory))] +public union UnionInnerWithClassifier(int, string); +public sealed class UnionInnerWithClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(int), + JsonTokenType.String => typeof(string), + _ => null, + }; +} + +// Outer nested union with a classifier'd inner union case. The outer pair (UnionInnerWithClassifier, bool) +// is token-distinct (Boolean vs {Number, String}), so the outer itself needs no classifier. +public union UnionOuterWithClassifier(UnionInnerWithClassifier, bool); + +// Outer nested union with classifiers on BOTH the outer and the inner. The outer classifier maps +// non-Boolean tokens to the inner union type so STJ recurses into UnionInnerWithClassifier, which +// then resolves the Number/String ambiguity. Demonstrates that nested unions are reachable end-to-end +// when callers opt in to classifiers at every level. +[JsonUnion(TypeClassifier = typeof(UnionOuterWithBothClassifiersFactory))] +public union UnionOuterWithBothClassifiers(UnionInnerWithClassifier, bool); +public sealed class UnionOuterWithBothClassifiersFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.True or JsonTokenType.False => typeof(bool), + JsonTokenType.Number or JsonTokenType.String => typeof(UnionInnerWithClassifier), + _ => null, + }; +} + +// Envelope that wraps a classifier'd inner union as one of its properties. The record itself is +// unambiguous; the classifier resolves the inner-union ambiguity that surfaces during binding. +public record UnionEnvelopeWithClassifier(string CorrelationId, UnionIntStringWithClassifier Payload); From ef98bba0d5972721fa04ce794cf557b9c9d0810b Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 29 May 2026 19:03:58 +0200 Subject: [PATCH 07/11] [frombody] and nullables --- .../RequestDelegateCreationTestBase.cs | 27 +++++ .../RequestDelegateCreationTests.JsonBody.cs | 5 +- ...DelegateCreationTests.JsonBodyOrService.cs | 5 +- ...equestDelegateCreationTests.Unions.Body.cs | 108 ++++++++++++++++++ 4 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs index 5215c7fc66e7..b0ce13142272 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs @@ -242,6 +242,33 @@ internal HttpContext CreateHttpContextWithJson(string requestData, IServiceProvi return httpContext; } + internal HttpContext CreateHttpContextWithEmptyJsonBody(IServiceProvider serviceProvider = null) + { + var httpContext = CreateHttpContext(serviceProvider); + httpContext.Request.Method = "POST"; + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "0"; + httpContext.Request.Body = new MemoryStream(Array.Empty()); + httpContext.Features.Set(new RequestBodyDetectionFeature(false)); + return httpContext; + } + + internal HttpContext CreateHttpContextWithCustomContentType(string payload, string contentType, IServiceProvider serviceProvider = null) + { + var httpContext = CreateHttpContext(serviceProvider); + httpContext.Request.Method = "POST"; + var bytes = Encoding.UTF8.GetBytes(payload); + var stream = new MemoryStream(bytes); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Length"] = stream.Length.ToString(CultureInfo.InvariantCulture); + if (contentType is not null) + { + httpContext.Request.Headers["Content-Type"] = contentType; + } + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + return httpContext; + } + internal static async Task GetResponseBodyAsync(HttpContext httpContext) { var httpResponse = httpContext.Response; diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBody.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBody.cs index cb0267759e92..cdf98afdb1a6 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBody.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBody.cs @@ -104,10 +104,7 @@ public async Task MapAction_ExplicitBodyParam_ComplexReturn_Returns400ForEmptyBo var (_, compilation) = await RunGeneratorAsync(source); var endpoint = GetEndpointFromCompilation(compilation); - var httpContext = CreateHttpContext(); - httpContext.Features.Set(new RequestBodyDetectionFeature(false)); - httpContext.Request.Headers["Content-Type"] = "application/json"; - httpContext.Request.Headers["Content-Length"] = "0"; + var httpContext = CreateHttpContextWithEmptyJsonBody(); await endpoint.RequestDelegate(httpContext); await VerifyResponseBodyAsync(httpContext, string.Empty, expectedStatusCode: 400); diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBodyOrService.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBodyOrService.cs index 10e9fc8b29ef..6ee46ce2d54c 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBodyOrService.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBodyOrService.cs @@ -194,10 +194,7 @@ public async Task RequestDelegateRejectsEmptyBodyGivenImplicitFromBodyParameter( }); var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); - var httpContext = CreateHttpContext(serviceProvider); - httpContext.Request.Headers["Content-Type"] = "application/json"; - httpContext.Request.Headers["Content-Length"] = "0"; - httpContext.Features.Set(new RequestBodyDetectionFeature(false)); + var httpContext = CreateHttpContextWithEmptyJsonBody(serviceProvider); var ex = await Assert.ThrowsAsync(() => endpoint.RequestDelegate(httpContext)); Assert.StartsWith("Implicit body inferred for parameter", ex.Message); diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs index bfdfd6dea0b7..5696baddb929 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs @@ -438,4 +438,112 @@ public async Task MapAction_UnionBody_HonorsConfigureHttpJsonOptions() Assert.Contains("\"payload\": 42", body); Assert.Contains("\n", body); } + + [Fact] + public async Task MapAction_UnionBody_ExplicitFromBody_BehavesLikeImplicitBody() + { + // [FromBody] on a union parameter should produce the same binding behavior as the + // implicit body inference. Both the happy-path (unambiguous Number) and the ambiguous + // (String) cases should match. + + var source = """ + app.MapPost("/implicit", (UnionIntString u) => u); + app.MapPost("/explicit", ([FromBody] UnionIntString u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToList(); + var implicitEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/implicit"); + var explicitEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/explicit"); + + var cases = new (string Payload, int ExpectedStatus, string ExpectedBody)[] + { + ("42", 200, "42"), + ("\"hi\"", 400, string.Empty), // String token is ambiguous for UnionIntString + }; + + foreach (var (payload, expectedStatus, expectedBody) in cases) + { + var implicitCtx = CreateHttpContextWithJson(payload); + await implicitEndpoint.RequestDelegate(implicitCtx); + Assert.True(implicitCtx.Response.StatusCode == expectedStatus, $"/implicit with payload {payload} expected {expectedStatus} but got {implicitCtx.Response.StatusCode}."); + if (expectedStatus == 200) + { + await VerifyResponseBodyAsync(implicitCtx, expectedBody); + } + + var explicitCtx = CreateHttpContextWithJson(payload); + await explicitEndpoint.RequestDelegate(explicitCtx); + Assert.True(explicitCtx.Response.StatusCode == expectedStatus, $"/explicit with payload {payload} expected {expectedStatus} but got {explicitCtx.Response.StatusCode}."); + if (expectedStatus == 200) + { + await VerifyResponseBodyAsync(explicitCtx, expectedBody); + } + } + } + + [Fact] + public async Task MapAction_UnionBody_EmptyBody_ReturnsExpectedStatusPerNullabilityAndPolicy() + { + // Three flavors of "what should the union parameter do when no request body is provided": + // 1. (UnionIntString u) — required → 400 (handler not invoked) + // 2. (UnionIntString? u) — nullable → 200, u is null + // 3. ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] UnionDoubleString u) — explicit allow → 200, u is default + + // NOTE: `/allow-empty` intentionally uses a DIFFERENT union type (UnionDoubleString) than the other two endpoints + // see https://github.com/dotnet/aspnetcore/issues/66912 + + var source = """ + app.MapPost("/required", (UnionIntString u) => "invoked"); + app.MapPost("/nullable", (UnionIntString? u) => u.HasValue ? "has-value" : "null"); + app.MapPost("/allow-empty", ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] UnionDoubleString u) => "invoked"); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToList(); + var requiredEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/required"); + var nullableEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/nullable"); + var allowEmptyEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/allow-empty"); + + var requiredCtx = CreateHttpContextWithEmptyJsonBody(); + await requiredEndpoint.RequestDelegate(requiredCtx); + Assert.Equal(400, requiredCtx.Response.StatusCode); + + // NOTE: response body differs across paths for `(UnionIntString? u)` with empty body: + // for runtime and source generator paths: https://github.com/dotnet/aspnetcore/issues/57055 + var nullableCtx = CreateHttpContextWithEmptyJsonBody(); + await nullableEndpoint.RequestDelegate(nullableCtx); + Assert.Equal(200, nullableCtx.Response.StatusCode); + + var allowEmptyCtx = CreateHttpContextWithEmptyJsonBody(); + await allowEmptyEndpoint.RequestDelegate(allowEmptyCtx); + Assert.Equal(200, allowEmptyCtx.Response.StatusCode); + } + + [Fact] + public async Task MapAction_UnionBody_NonJsonContentType_Returns415() + { + // The body-bind path rejects non-JSON content types with 415, mirroring the behavior + // for ordinary complex body parameters. Asserts: + // - application/json (baseline) → 200 + // - text/plain → 415 + // - (no Content-Type header at all) → 415 + + var source = """ + app.MapPost("/", (UnionIntString u) => u); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation).OfType().Single(); + + var baselineCtx = CreateHttpContextWithJson("42"); + await endpoint.RequestDelegate(baselineCtx); + Assert.Equal(200, baselineCtx.Response.StatusCode); + await VerifyResponseBodyAsync(baselineCtx, "42"); + + var textPlainCtx = CreateHttpContextWithCustomContentType("42", contentType: "text/plain"); + await endpoint.RequestDelegate(textPlainCtx); + Assert.Equal(415, textPlainCtx.Response.StatusCode); + + var noContentTypeCtx = CreateHttpContextWithCustomContentType("42", contentType: null); + await endpoint.RequestDelegate(noContentTypeCtx); + Assert.Equal(415, noContentTypeCtx.Response.StatusCode); + } } From 241a83aa98fe657769aaae02567090ec32213791 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 1 Jun 2026 09:31:07 +0200 Subject: [PATCH 08/11] [AsParameters] , Envelope as input object and checking multiple body parameters are not bound --- ...equestDelegateCreationTests.Unions.Body.cs | 89 +++++++++++++++++++ .../SharedTypes.Unions.cs | 14 +++ 2 files changed, 103 insertions(+) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs index 5696baddb929..7db0421baeb1 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs @@ -546,4 +546,93 @@ public async Task MapAction_UnionBody_NonJsonContentType_Returns415() await endpoint.RequestDelegate(noContentTypeCtx); Assert.Equal(415, noContentTypeCtx.Response.StatusCode); } + + [Theory] + [InlineData("(UnionIntString a, UnionPet b) => \"x\"")] + [InlineData("(UnionIntString u, Todo t) => \"x\"")] + public async Task MapAction_UnionBody_MultipleBodyParameters_ThrowsAtEndpointBuild(string handler) + { + // Minimal API allows at most one body-bound parameter per endpoint. + + var source = $$""" + app.MapPost("/", {{handler}}); + """; + var (_, compilation) = await RunGeneratorAsync(source); + + Assert.Throws(() => GetEndpointFromCompilation(compilation)); + } + + [Fact] + public async Task MapAction_UnionBody_NestedInWrapperDto_DeserializesUnionProperty() + { + // UnionEnvelope wraps a UnionIntString as a property. + + var source = """ + app.MapPost("/envelope", (UnionEnvelope e) => e); + app.MapPost("/envelope-classifier", (UnionEnvelopeWithClassifier e) => e); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToList(); + var bareEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/envelope"); + var classifierEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/envelope-classifier"); + + var bareIntCtx = CreateHttpContextWithJson("""{"correlationId":"abc","payload":42}"""); + await bareEndpoint.RequestDelegate(bareIntCtx); + Assert.Equal(200, bareIntCtx.Response.StatusCode); + await VerifyResponseBodyAsync(bareIntCtx, """{"correlationId":"abc","payload":42}"""); + + // ambiguous for string case without classifier + var bareStringCtx = CreateHttpContextWithJson("""{"correlationId":"abc","payload":"hi"}"""); + await bareEndpoint.RequestDelegate(bareStringCtx); + Assert.Equal(400, bareStringCtx.Response.StatusCode); + + var classifierIntCtx = CreateHttpContextWithJson("""{"correlationId":"abc","payload":42}"""); + await classifierEndpoint.RequestDelegate(classifierIntCtx); + Assert.Equal(200, classifierIntCtx.Response.StatusCode); + await VerifyResponseBodyAsync(classifierIntCtx, """{"correlationId":"abc","payload":42}"""); + + var classifierStringCtx = CreateHttpContextWithJson("""{"correlationId":"abc","payload":"hi"}"""); + await classifierEndpoint.RequestDelegate(classifierStringCtx); + Assert.Equal(200, classifierStringCtx.Response.StatusCode); + await VerifyResponseBodyAsync(classifierStringCtx, """{"correlationId":"abc","payload":"hi"}"""); + } + + [Fact] + public async Task MapAction_UnionBody_AsParametersContainer_BindsUnionFromBodyAndRouteFromRoute() + { + // [AsParameters] unwraps the container and binds each property: union → body, TenantId → route + + var source = """ + app.MapPost("/tenants/{TenantId}", ([AsParameters] UnionAsParametersList args) => $"{args.TenantId}:{args.Payload.Value}"); + app.MapPost("/classifier-tenants/{TenantId}", ([AsParameters] UnionWithClassifierAsParametersList args) => $"{args.TenantId}:{args.Payload.Value}"); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoints = GetEndpointsFromCompilation(compilation).OfType().ToList(); + var bareEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/tenants/{TenantId}"); + var classifierEndpoint = endpoints.Single(e => e.RoutePattern.RawText == "/classifier-tenants/{TenantId}"); + + var bareIntCtx = CreateHttpContextWithJson("42"); + bareIntCtx.Request.RouteValues["TenantId"] = "7"; + await bareEndpoint.RequestDelegate(bareIntCtx); + Assert.Equal(200, bareIntCtx.Response.StatusCode); + await VerifyResponseBodyAsync(bareIntCtx, "7:42"); + + // ambiguous for string case without classifier + var bareStringCtx = CreateHttpContextWithJson("\"hi\""); + bareStringCtx.Request.RouteValues["TenantId"] = "9"; + await bareEndpoint.RequestDelegate(bareStringCtx); + Assert.Equal(400, bareStringCtx.Response.StatusCode); + + var classifierIntCtx = CreateHttpContextWithJson("42"); + classifierIntCtx.Request.RouteValues["TenantId"] = "7"; + await classifierEndpoint.RequestDelegate(classifierIntCtx); + Assert.Equal(200, classifierIntCtx.Response.StatusCode); + await VerifyResponseBodyAsync(classifierIntCtx, "7:42"); + + var classifierStringCtx = CreateHttpContextWithJson("\"hi\""); + classifierStringCtx.Request.RouteValues["TenantId"] = "9"; + await classifierEndpoint.RequestDelegate(classifierStringCtx); + Assert.Equal(200, classifierStringCtx.Response.StatusCode); + await VerifyResponseBodyAsync(classifierStringCtx, "9:hi"); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs index 93fe98b850ac..aaae077eadb6 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -3,6 +3,8 @@ using System; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; namespace Http; @@ -291,3 +293,15 @@ public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContex // Envelope that wraps a classifier'd inner union as one of its properties. The record itself is // unambiguous; the classifier resolves the inner-union ambiguity that surfaces during binding. public record UnionEnvelopeWithClassifier(string CorrelationId, UnionIntStringWithClassifier Payload); + +// Container for [AsParameters] tests where a union property is the body slot and +// a sibling property comes from the route. Verifies that the generator unwraps the +// container and applies the standard body-inference cascade to the union property +// while the non-union property is routed/queried independently. +public record UnionAsParametersList(HttpContext HttpContext, [FromRoute] int TenantId, UnionIntString Payload); + +// Same shape as UnionAsParametersList but the union has a JsonUnion classifier wired +// up. Used to verify both case types (int and string) bind through the body inside an +// [AsParameters] container — the bare UnionIntString variant is ambiguous on the String +// token under web defaults, so we need a classifier to cover the string-case path. +public record UnionWithClassifierAsParametersList(HttpContext HttpContext, [FromRoute] int TenantId, UnionIntStringWithClassifier Payload); From acce24c231f27baf56553c7bf8a03cbaf05f31d2 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 1 Jun 2026 10:14:32 +0200 Subject: [PATCH 09/11] fix tests + try it in sample app --- ...equestDelegateCreationTests.Unions.Body.cs | 15 ---- .../SharedTypes.Unions.cs | 2 +- src/Http/samples/MinimalSample/Program.cs | 88 +++++++++++++++++++ 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs index 7db0421baeb1..31a7af3859df 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs @@ -547,21 +547,6 @@ public async Task MapAction_UnionBody_NonJsonContentType_Returns415() Assert.Equal(415, noContentTypeCtx.Response.StatusCode); } - [Theory] - [InlineData("(UnionIntString a, UnionPet b) => \"x\"")] - [InlineData("(UnionIntString u, Todo t) => \"x\"")] - public async Task MapAction_UnionBody_MultipleBodyParameters_ThrowsAtEndpointBuild(string handler) - { - // Minimal API allows at most one body-bound parameter per endpoint. - - var source = $$""" - app.MapPost("/", {{handler}}); - """; - var (_, compilation) = await RunGeneratorAsync(source); - - Assert.Throws(() => GetEndpointFromCompilation(compilation)); - } - [Fact] public async Task MapAction_UnionBody_NestedInWrapperDto_DeserializesUnionProperty() { diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs index aaae077eadb6..2a01710cdf83 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Http; +namespace Microsoft.AspNetCore.Http.Generators.Tests; #nullable enable diff --git a/src/Http/samples/MinimalSample/Program.cs b/src/Http/samples/MinimalSample/Program.cs index b90c3a3cb48c..cc2426ed6414 100644 --- a/src/Http/samples/MinimalSample/Program.cs +++ b/src/Http/samples/MinimalSample/Program.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; @@ -94,9 +96,95 @@ app.MapPost("/todos", (TodoBindable todo) => todo); app.MapGet("/todos", () => new Todo[] { new Todo(1, "Walk the dog"), new Todo(2, "Come back home") }); +// GET /union/echo?value=42 → 42 wrapped in UnionIntStringWithClassifier, serialized as 42 +// GET /union/echo?value=hi → "hi" +app.MapGet("/union/echo", ([FromQuery] string value) => + int.TryParse(value, out var n) + ? new UnionIntStringWithClassifier(n) + : new UnionIntStringWithClassifier(value)); + +// POST /union/parse body: 42 → "got int: 42" +// POST /union/parse body: "hi" → "got string: hi" +app.MapPost("/union/parse", (UnionIntStringWithClassifier u) => u.Value switch +{ + int i => $"got int: {i}", + string s => $"got string: {s}", + _ => throw new NotImplementedException() +}); + +// POST /union/envelope body: {"correlationId":"abc","payload":42} +// or {"correlationId":"abc","payload":"hi"} +app.MapPost("/union/envelope", (UnionEnvelopeWithClassifier e) => e); + +// POST /union/pet body: {"name":"Whiskers"} → Cat +// {"breed":"Husky"} → Dog +app.MapPost("/union/pet", (UnionPetWithClassifier pet) => pet.Value switch +{ + Cat c => $"cat: {c.Name}", + Dog d => $"dog: {d.Breed}", + _ => "unknown", +}); + app.Run(); internal record Todo(int Id, string Title); + +[JsonUnion(TypeClassifier = typeof(UnionIntStringClassifierFactory))] +public union UnionIntStringWithClassifier(int, string); + +public sealed class UnionIntStringClassifierFactory + : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier( + JsonTypeClassifierContext context, + JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => typeof(int), + JsonTokenType.String => typeof(string), + _ => null, + }; +} + +public record UnionEnvelopeWithClassifier(string CorrelationId, UnionIntStringWithClassifier Payload); + +public record Cat(string Name); +public record Dog(string Breed); + +[JsonUnion(TypeClassifier = typeof(UnionPetClassifierFactory))] +public union UnionPetWithClassifier(Cat, Dog); + +public sealed class UnionPetClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier( + JsonTypeClassifierContext context, + JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => + { + if (reader.TokenType != JsonTokenType.StartObject) + { + return null; + } + + var clone = reader; + clone.Read(); + while (clone.TokenType == JsonTokenType.PropertyName) + { + if (clone.ValueTextEquals("name") || clone.ValueTextEquals("Name")) + { + return typeof(Cat); + } + if (clone.ValueTextEquals("breed") || clone.ValueTextEquals("Breed")) + { + return typeof(Dog); + } + clone.Read(); + clone.Skip(); + clone.Read(); + } + return null; + }; +} public class TodoBindable : IBindableFromHttpContext { public int Id { get; set; } From 47b8421a75ab79da614c1ab3b8228936bec2cf7c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 1 Jun 2026 11:16:08 +0200 Subject: [PATCH 10/11] required payload test --- ...equestDelegateCreationTests.Unions.Body.cs | 30 +++++++++++++++++++ .../SharedTypes.Unions.cs | 7 +++++ src/Http/samples/MinimalSample/Program.cs | 9 ++++++ 3 files changed, 46 insertions(+) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs index 31a7af3859df..eeb7a832fd1f 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs @@ -620,4 +620,34 @@ public async Task MapAction_UnionBody_AsParametersContainer_BindsUnionFromBodyAn Assert.Equal(200, classifierStringCtx.Response.StatusCode); await VerifyResponseBodyAsync(classifierStringCtx, "9:hi"); } + + [Fact] + public async Task MapAction_UnionBody_NestedInWrapperDto_JsonRequiredOnUnionProperty_FailsOnMissing() + { + // Verifies that [JsonRequired] on a union property of a wrapper DTO behaves the same as for any other property type + + var source = """ + app.MapPost("/envelope-required", (UnionEnvelopeWithRequiredPayload e) => e); + """; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointsFromCompilation(compilation) + .OfType() + .Single(e => e.RoutePattern.RawText == "/envelope-required"); + + // payload key present: succeeds and round-trips + var presentCtx = CreateHttpContextWithJson("""{"correlationId":"abc","payload":42}"""); + await endpoint.RequestDelegate(presentCtx); + Assert.Equal(200, presentCtx.Response.StatusCode); + await VerifyResponseBodyAsync(presentCtx, """{"correlationId":"abc","payload":42}"""); + + // payload key missing: STJ enforces [JsonRequired] → 400 + var missingCtx = CreateHttpContextWithJson("""{"correlationId":"abc"}"""); + await endpoint.RequestDelegate(missingCtx); + Assert.Equal(400, missingCtx.Response.StatusCode); + + // payload explicitly null: the union converter rejects null on read → 400 + var explicitNullCtx = CreateHttpContextWithJson("""{"correlationId":"abc","payload":null}"""); + await endpoint.RequestDelegate(explicitNullCtx); + Assert.Equal(400, explicitNullCtx.Response.StatusCode); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs index 2a01710cdf83..b3dfaad679b0 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -294,6 +294,13 @@ public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContex // unambiguous; the classifier resolves the inner-union ambiguity that surfaces during binding. public record UnionEnvelopeWithClassifier(string CorrelationId, UnionIntStringWithClassifier Payload); +// Same as UnionEnvelopeWithClassifier but the union property is marked [JsonRequired]. +// Used to verify STJ rejects a missing "payload" key on read, instead of silently producing +// a default(union) value that would later trip the union converter on the write path. +public record UnionEnvelopeWithRequiredPayload( + string CorrelationId, + [property: JsonRequired] UnionIntStringWithClassifier Payload); + // Container for [AsParameters] tests where a union property is the body slot and // a sibling property comes from the route. Verifies that the generator unwraps the // container and applies the standard body-inference cascade to the union property diff --git a/src/Http/samples/MinimalSample/Program.cs b/src/Http/samples/MinimalSample/Program.cs index cc2426ed6414..3ecdb2b92665 100644 --- a/src/Http/samples/MinimalSample/Program.cs +++ b/src/Http/samples/MinimalSample/Program.cs @@ -93,6 +93,8 @@ }); +app.MapPost("/weather", (Weather weather) => weather); + app.MapPost("/todos", (TodoBindable todo) => todo); app.MapGet("/todos", () => new Todo[] { new Todo(1, "Walk the dog"), new Todo(2, "Come back home") }); @@ -185,6 +187,13 @@ public override JsonTypeClassifier CreateJsonClassifier( return null; }; } + +public class Weather +{ + public int TemperatureC { get; set; } + public string? Summary { get; set; } +} + public class TodoBindable : IBindableFromHttpContext { public int Id { get; set; } From 06297ffcc48cfea4e8a11ba2a92d8932ff12e547 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 3 Jun 2026 14:26:39 +0200 Subject: [PATCH 11/11] address PR comments --- .../RequestDelegateCreationTests.Unions.Body.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs index eeb7a832fd1f..25898c5dd3b1 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs @@ -10,11 +10,11 @@ namespace Microsoft.AspNetCore.Http.Generators.Tests; public abstract partial class RequestDelegateCreationTests : RequestDelegateCreationTestBase { [Theory] - [InlineData("true", "true", "Boolean")] - [InlineData("false", "false", "Boolean")] - [InlineData("\"hi\"", "\"hi\"", "String")] + [InlineData("true", "true")] + [InlineData("false", "false")] + [InlineData("\"hi\"", "\"hi\"")] public async Task MapAction_UnionBody_NaturallyUnambiguousPrimitiveCases_RoundTrip( - string requestJson, string expectedBody, string tokenKindForReadability) + string requestJson, string expectedBody) { var source = """ app.MapPost("/bool-string", (UnionBoolString u) => u); @@ -27,7 +27,7 @@ public async Task MapAction_UnionBody_NaturallyUnambiguousPrimitiveCases_RoundTr var ctx = CreateHttpContextWithJson(requestJson); await endpoint.RequestDelegate(ctx); - Assert.True(ctx.Response.StatusCode == 200, $"UnionBoolString body bind for {tokenKindForReadability} payload {requestJson} should return 200 but got {ctx.Response.StatusCode}."); + Assert.True(ctx.Response.StatusCode == 200); await VerifyResponseBodyAsync(ctx, expectedBody); } @@ -178,9 +178,9 @@ public async Task MapAction_UnionBody_UnionWithNullableCase_FailsWithoutClassifi Assert.True(stringCtx.Response.StatusCode == 400, $"UnionNullableIntString body bind for {"\"hi\""} should return 400 but got {stringCtx.Response.StatusCode}."); // TODO enable after fix https://github.com/dotnet/runtime/issues/128688 - var nullCtx = CreateHttpContextWithJson("null"); - await endpoints.Single(e => e.RoutePattern.RawText == "/nullable-int-string").RequestDelegate(stringCtx); - Assert.True(stringCtx.Response.StatusCode == 400, $"UnionNullableIntString body bind for \"null\" should return 400 but got {stringCtx.Response.StatusCode}."); + //var nullCtx = CreateHttpContextWithJson("null"); + //await endpoints.Single(e => e.RoutePattern.RawText == "/nullable-int-string").RequestDelegate(nullCtx); + //Assert.True(nullCtx.Response.StatusCode == 400, $"UnionNullableIntString body bind for \"null\" should return 400 but got {nullCtx.Response.StatusCode}."); // Classifier endpoint accepts and round-trips every token kind. foreach (var (payload, expected) in new[]