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 new file mode 100644 index 000000000000..25898c5dd3b1 --- /dev/null +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Body.cs @@ -0,0 +1,653 @@ +// 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; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Http.Generators.Tests; + +public abstract partial class RequestDelegateCreationTests : RequestDelegateCreationTestBase +{ + [Theory] + [InlineData("true", "true")] + [InlineData("false", "false")] + [InlineData("\"hi\"", "\"hi\"")] + public async Task MapAction_UnionBody_NaturallyUnambiguousPrimitiveCases_RoundTrip( + string requestJson, string expectedBody) + { + 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); + 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); + } + + [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(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[] + { + ("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); + } + + [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); + } + + [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"); + } + + [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/RequestDelegateCreationTests.Unions.Responses.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Responses.cs new file mode 100644 index 000000000000..441e149ba72c --- /dev/null +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Unions.Responses.cs @@ -0,0 +1,439 @@ +// 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; +using Microsoft.Extensions.DependencyInjection; + +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); + } + + // 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("/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 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] + 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 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(); + + var intCtx = CreateHttpContext(); + await endpoints.Single(e => e.RoutePattern.RawText == "/int").RequestDelegate(intCtx); + await VerifyResponseBodyAsync(intCtx, "5"); + + // 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); + await VerifyResponseBodyAsync(stringCtx, "\"hi\""); + } + + [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"))); + """; + 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\"}"); + } + + [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. + + 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. + + 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\""); + } + + [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"); + } + + [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); + } +} 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..b3dfaad679b0 --- /dev/null +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.Unions.cs @@ -0,0 +1,314 @@ +// 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; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Http.Generators.Tests; + +#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) + +// Nullable case +public union UnionNullableIntString(int?, string); + +// 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); + +// 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 +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 +public sealed class IntFirstClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + 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, + }; +} + +// 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); + +// 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 +// 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); diff --git a/src/Http/samples/MinimalSample/Program.cs b/src/Http/samples/MinimalSample/Program.cs index b90c3a3cb48c..3ecdb2b92665 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; @@ -91,12 +93,107 @@ }); +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") }); +// 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 Weather +{ + public int TemperatureC { get; set; } + public string? Summary { get; set; } +} + public class TodoBindable : IBindableFromHttpContext { public int Id { get; set; }