diff --git a/src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs b/src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs index c8159b186cf6..cbcb79be7c7d 100644 --- a/src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs +++ b/src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs @@ -149,7 +149,10 @@ private static Func ResolveFromRouteOrQuery(string pa """; public static string WriteToResponseAsyncMethod => """ - private static Task WriteToResponseAsync(T? value, HttpContext httpContext, JsonTypeInfo jsonTypeInfo, JsonSerializerOptions options) + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "The 'JsonSerializer.IsReflectionEnabledByDefault' feature switch, which is set to false by default for trimmed ASP.NET apps, ensures the JsonSerializer doesn't use Reflection.")] + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "See above.")] + private static Task WriteToResponseAsync(T? value, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { var runtimeType = value?.GetType(); if (runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.PolymorphismOptions is not null) @@ -157,7 +160,7 @@ private static Task WriteToResponseAsync(T? value, HttpContext httpContext, J return httpContext.Response.WriteAsJsonAsync(value!, jsonTypeInfo); } - return httpContext.Response.WriteAsJsonAsync(value!, options.GetTypeInfo(runtimeType)); + return httpContext.Response.WriteAsJsonAsync(value, jsonTypeInfo.Options); } """; diff --git a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointJsonResponseEmitter.cs b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointJsonResponseEmitter.cs index 826c6c268230..3694de260f88 100644 --- a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointJsonResponseEmitter.cs +++ b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointJsonResponseEmitter.cs @@ -23,6 +23,6 @@ internal static string EmitJsonResponse(this EndpointResponse endpointResponse) { return $"httpContext.Response.WriteAsJsonAsync(result, jsonTypeInfo);"; } - return $"GeneratedRouteBuilderExtensionsCore.WriteToResponseAsync(result, httpContext, jsonTypeInfo, serializerOptions);"; + return $"GeneratedRouteBuilderExtensionsCore.WriteToResponseAsync(result, httpContext, jsonTypeInfo);"; } } diff --git a/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs b/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs index d2ec3b1e516e..3f783c7b6cd2 100644 --- a/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs +++ b/src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs @@ -30,6 +30,8 @@ public static partial class HttpResponseJsonExtensions /// The value to write as JSON. /// A used to cancel the operation. /// The task object representing the asynchronous operation. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] public static Task WriteAsJsonAsync( this HttpResponse response, TValue value, @@ -37,8 +39,7 @@ public static Task WriteAsJsonAsync( { ArgumentNullException.ThrowIfNull(response); - var options = ResolveSerializerOptions(response.HttpContext); - return response.WriteAsJsonAsync(value, jsonTypeInfo: (JsonTypeInfo)options.GetTypeInfo(typeof(TValue)), contentType: null, cancellationToken); + return response.WriteAsJsonAsync(value, options: null, contentType: null, cancellationToken); } /// @@ -108,9 +109,7 @@ public static Task WriteAsJsonAsync( /// The content-type to set on the response. /// A used to cancel the operation. /// The task object representing the asynchronous operation. -#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters public static Task WriteAsJsonAsync( -#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters this HttpResponse response, TValue value, JsonTypeInfo jsonTypeInfo, @@ -204,6 +203,8 @@ private static async Task WriteAsJsonAsyncSlow( /// The type of object to write. /// A used to cancel the operation. /// The task object representing the asynchronous operation. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] public static Task WriteAsJsonAsync( this HttpResponse response, object? value, @@ -212,8 +213,7 @@ public static Task WriteAsJsonAsync( { ArgumentNullException.ThrowIfNull(response); - var options = ResolveSerializerOptions(response.HttpContext); - return response.WriteAsJsonAsync(value, jsonTypeInfo: options.GetTypeInfo(type), contentType: null, cancellationToken); + return response.WriteAsJsonAsync(value, type, options: null, contentType: null, cancellationToken); } /// @@ -302,9 +302,7 @@ private static async Task WriteAsJsonAsyncSlow( /// The content-type to set on the response. /// A used to cancel the operation. /// The task object representing the asynchronous operation. -#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters public static Task WriteAsJsonAsync( -#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters this HttpResponse response, object? value, Type type, diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index 7b24f3f42996..79c1663585f2 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -275,7 +275,6 @@ private static RequestDelegateFactoryContext CreateFactoryContext(RequestDelegat EndpointBuilder = endpointBuilder, MetadataAlreadyInferred = metadataResult is not null, JsonSerializerOptions = jsonSerializerOptions, - JsonSerializerOptionsExpression = Expression.Constant(jsonSerializerOptions, typeof(JsonSerializerOptions)), }; return factoryContext; @@ -1000,7 +999,6 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, ExecuteAwaitedReturnMethod, methodCall, HttpContextExpr, - factoryContext.JsonSerializerOptionsExpression, Expression.Constant(factoryContext.JsonSerializerOptions.GetReadOnlyTypeInfo(typeof(object)), typeof(JsonTypeInfo))); } else if (returnType == typeof(ValueTask)) @@ -1008,7 +1006,6 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, return Expression.Call(ExecuteValueTaskOfObjectMethod, methodCall, HttpContextExpr, - factoryContext.JsonSerializerOptionsExpression, Expression.Constant(factoryContext.JsonSerializerOptions.GetReadOnlyTypeInfo(typeof(object)), typeof(JsonTypeInfo))); } else if (returnType == typeof(Task)) @@ -1016,7 +1013,6 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, return Expression.Call(ExecuteTaskOfObjectMethod, methodCall, HttpContextExpr, - factoryContext.JsonSerializerOptionsExpression, Expression.Constant(factoryContext.JsonSerializerOptions.GetReadOnlyTypeInfo(typeof(object)), typeof(JsonTypeInfo))); } else if (AwaitableInfo.IsTypeAwaitable(returnType, out _)) @@ -1068,7 +1064,6 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, ExecuteTaskOfTMethod.MakeGenericMethod(typeArg), methodCall, HttpContextExpr, - factoryContext.JsonSerializerOptionsExpression, Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(typeArg))); } } @@ -1109,7 +1104,6 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, ExecuteValueTaskOfTMethod.MakeGenericMethod(typeArg), methodCall, HttpContextExpr, - factoryContext.JsonSerializerOptionsExpression, Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(typeArg))); } } @@ -1154,7 +1148,6 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, JsonResultWriteResponseOfTAsyncMethod.MakeGenericMethod(returnType), HttpResponseExpr, methodCall, - factoryContext.JsonSerializerOptionsExpression, Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(returnType))); } } @@ -2107,39 +2100,39 @@ private static MemberInfo GetMemberInfo(Expression expr) // if necessary and restart the cycle until we've reached a terminal state (unknown type). // We currently don't handle Task or ValueTask. We can support this later if this // ends up being a common scenario. - private static Task ExecuteValueTaskOfObject(ValueTask valueTask, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + private static Task ExecuteValueTaskOfObject(ValueTask valueTask, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - static async Task ExecuteAwaited(ValueTask valueTask, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + static async Task ExecuteAwaited(ValueTask valueTask, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - await ExecuteAwaitedReturn(await valueTask, httpContext, options, jsonTypeInfo); + await ExecuteAwaitedReturn(await valueTask, httpContext, jsonTypeInfo); } if (valueTask.IsCompletedSuccessfully) { - return ExecuteAwaitedReturn(valueTask.GetAwaiter().GetResult(), httpContext, options, jsonTypeInfo); + return ExecuteAwaitedReturn(valueTask.GetAwaiter().GetResult(), httpContext, jsonTypeInfo); } - return ExecuteAwaited(valueTask, httpContext, options, jsonTypeInfo); + return ExecuteAwaited(valueTask, httpContext, jsonTypeInfo); } - private static Task ExecuteTaskOfObject(Task task, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + private static Task ExecuteTaskOfObject(Task task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - await ExecuteAwaitedReturn(await task, httpContext, options, jsonTypeInfo); + await ExecuteAwaitedReturn(await task, httpContext, jsonTypeInfo); } if (task.IsCompletedSuccessfully) { - return ExecuteAwaitedReturn(task.GetAwaiter().GetResult(), httpContext, options, jsonTypeInfo); + return ExecuteAwaitedReturn(task.GetAwaiter().GetResult(), httpContext, jsonTypeInfo); } - return ExecuteAwaited(task, httpContext, options, jsonTypeInfo); + return ExecuteAwaited(task, httpContext, jsonTypeInfo); } - private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - return ExecuteHandlerHelper.ExecuteReturnAsync(obj, httpContext, options, jsonTypeInfo); + return ExecuteHandlerHelper.ExecuteReturnAsync(obj, httpContext, jsonTypeInfo); } private static Task ExecuteTaskOfTFast(Task task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) @@ -2159,21 +2152,21 @@ static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonType return ExecuteAwaited(task, httpContext, jsonTypeInfo); } - private static Task ExecuteTaskOfT(Task task, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + private static Task ExecuteTaskOfT(Task task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { EnsureRequestTaskNotNull(task); - static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - await WriteJsonResponse(httpContext.Response, await task, options, jsonTypeInfo); + await WriteJsonResponse(httpContext.Response, await task, jsonTypeInfo); } if (task.IsCompletedSuccessfully) { - return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult(), options, jsonTypeInfo); + return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult(), jsonTypeInfo); } - return ExecuteAwaited(task, httpContext, options, jsonTypeInfo); + return ExecuteAwaited(task, httpContext, jsonTypeInfo); } private static Task ExecuteTaskOfString(Task task, HttpContext httpContext) @@ -2264,19 +2257,19 @@ static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext, Jso return ExecuteAwaited(task, httpContext, jsonTypeInfo); } - private static Task ExecuteValueTaskOfT(ValueTask task, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + private static Task ExecuteValueTaskOfT(ValueTask task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - await WriteJsonResponse(httpContext.Response, await task, options, jsonTypeInfo); + await WriteJsonResponse(httpContext.Response, await task, jsonTypeInfo); } if (task.IsCompletedSuccessfully) { - return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult(), options, jsonTypeInfo); + return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult(), jsonTypeInfo); } - return ExecuteAwaited(task, httpContext, options, jsonTypeInfo); + return ExecuteAwaited(task, httpContext, jsonTypeInfo); } private static Task ExecuteValueTaskOfString(ValueTask task, HttpContext httpContext) @@ -2328,9 +2321,9 @@ private static async Task ExecuteResultWriteResponse(IResult? result, HttpContex private static Task WriteJsonResponseFast(HttpResponse response, T value, JsonTypeInfo jsonTypeInfo) => HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, jsonTypeInfo, default); - private static Task WriteJsonResponse(HttpResponse response, T? value, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + private static Task WriteJsonResponse(HttpResponse response, T? value, JsonTypeInfo jsonTypeInfo) { - return ExecuteHandlerHelper.WriteJsonResponseAsync(response, value, options, jsonTypeInfo); + return ExecuteHandlerHelper.WriteJsonResponseAsync(response, value, jsonTypeInfo); } private static NotSupportedException GetUnsupportedReturnTypeException(Type returnType) diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs b/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs index 04c5d526f635..d3c793fbe642 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs @@ -59,5 +59,4 @@ internal sealed class RequestDelegateFactoryContext // Grab these options upfront to avoid the per request DI scope that would be made otherwise to get the options when writing Json public required JsonSerializerOptions JsonSerializerOptions { get; set; } - public required Expression JsonSerializerOptionsExpression { get; set; } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index dc20594eafcf..14cd2060dba2 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -2033,46 +2033,11 @@ public async Task RequestDelegateWritesJsonTypeDiscriminatorToJsonResponseBody_W Assert.Equal(nameof(JsonTodoChild), deserializedResponseBody["$type"]!.GetValue()); } - public static IEnumerable JsonContextActions - { - get - { - return ComplexResult.Concat(ChildResult); - } - } - [JsonSerializable(typeof(Todo))] [JsonSerializable(typeof(TodoChild))] private partial class TestJsonContext : JsonSerializerContext { } - [Theory] - [MemberData(nameof(JsonContextActions))] - public async Task RequestDelegateWritesAsJsonResponseBody_WithJsonSerializerContext(Delegate @delegate) - { - var httpContext = CreateHttpContext(); - httpContext.RequestServices = new ServiceCollection() - .AddSingleton(LoggerFactory) - .ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolver = TestJsonContext.Default) - .BuildServiceProvider(); - - var responseBodyStream = new MemoryStream(); - httpContext.Response.Body = responseBodyStream; - - var factoryResult = RequestDelegateFactory.Create(@delegate); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - var deserializedResponseBody = JsonSerializer.Deserialize(responseBodyStream.ToArray(), new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - Assert.NotNull(deserializedResponseBody); - Assert.Equal("Write even more tests!", deserializedResponseBody!.Name); - } - [Fact] public void CreateDelegateThrows_WhenGetJsonTypeInfoFail() { diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs index 16e8f57be7fd..b5f5fea0c89a 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs @@ -192,8 +192,6 @@ public ServiceProvider CreateServiceProvider(Action configur { var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(LoggerFactory); - var jsonOptions = new JsonOptions(); - serviceCollection.AddSingleton(Options.Create(jsonOptions)); if (configureServices is not null) { configureServices(serviceCollection); diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Responses.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Responses.cs index d756039e2453..d44748def6d8 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Responses.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Responses.cs @@ -1,9 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Routing.Internal; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Primitives; using System.Text; +using System.Text.Json; namespace Microsoft.AspNetCore.Http.Generators.Tests; @@ -248,4 +250,104 @@ public async Task MapAction_HandlesCompletedTaskReturn() await endpoints[1].RequestDelegate(httpContext); await VerifyResponseBodyAsync(httpContext, string.Empty); } + + public static IEnumerable JsonContextActions + { + get + { + yield return new[] { "TestAction", """Todo TestAction() => new Todo { Name = "Write even more tests!" };""" }; + yield return new[] { "TaskTestAction", """Task TaskTestAction() => Task.FromResult(new Todo { Name = "Write even more tests!" });""" }; + yield return new[] { "ValueTaskTestAction", """ValueTask ValueTaskTestAction() => ValueTask.FromResult(new Todo { Name = "Write even more tests!" });""" }; + + yield return new[] { "StaticTestAction", """static Todo StaticTestAction() => new Todo { Name = "Write even more tests!" };""" }; + yield return new[] { "StaticTaskTestAction", """static Task StaticTaskTestAction() => Task.FromResult(new Todo { Name = "Write even more tests!" });""" }; + yield return new[] { "StaticValueTaskTestAction", """static ValueTask StaticValueTaskTestAction() => ValueTask.FromResult(new Todo { Name = "Write even more tests!" });""" }; + + yield return new[] { "TestAction", """Todo TestAction() => new JsonTodoChild { Name = "Write even more tests!", Child = "With type hierarchies!" };""" }; + + yield return new[] { "TaskTestAction", """Task TaskTestAction() => Task.FromResult(new JsonTodoChild { Name = "Write even more tests!", Child = "With type hierarchies!" });""" }; + yield return new[] { "TaskTestActionAwaited", """ + async Task TaskTestActionAwaited() + { + await Task.Yield(); + return new JsonTodoChild { Name = "Write even more tests!", Child = "With type hierarchies!" }; + } + """ }; + + yield return new[] { "ValueTaskTestAction", """ValueTask ValueTaskTestAction() => ValueTask.FromResult(new JsonTodoChild { Name = "Write even more tests!", Child = "With type hierarchies!" });""" }; + yield return new[] { "ValueTaskTestActionAwaited", """ + async ValueTask ValueTaskTestActionAwaited() + { + await Task.Yield(); + return new JsonTodoChild { Name = "Write even more tests!", Child = "With type hierarchies!" }; + } + """ }; + } + } + + [Theory] + [MemberData(nameof(JsonContextActions))] + public async Task RequestDelegateWritesAsJsonResponseBody_WithJsonSerializerContext(string delegateName, string delegateSource) + { + var source = $""" +app.MapGet("/test", {delegateName}); + +{delegateSource} +"""; + + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolver = SharedTestJsonContext.Default); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(serviceProvider); + + await endpoint.RequestDelegate(httpContext); + + var deserializedResponseBody = JsonSerializer.Deserialize(((MemoryStream)httpContext.Response.Body).ToArray(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + Assert.NotNull(deserializedResponseBody); + Assert.Equal("Write even more tests!", deserializedResponseBody!.Name); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateWritesAsJsonResponseBody_UnspeakableType(bool useJsonContext) + { + var source = """ +app.MapGet("/todos", () => GetTodosAsync()); + +static async IAsyncEnumerable GetTodosAsync() +{ + yield return new JsonTodo() { Id = 1, IsComplete = true, Name = "One" }; + + // ensure this is async + await Task.Yield(); + + yield return new JsonTodoChild() { Id = 2, IsComplete = false, Name = "Two", Child = "TwoChild" }; +} +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + if (useJsonContext) + { + serviceCollection.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolver = SharedTestJsonContext.Default); + } + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(serviceProvider); + + await endpoint.RequestDelegate(httpContext); + + var expectedBody = """[{"id":1,"name":"One","isComplete":true},{"$type":"JsonTodoChild","child":"TwoChild","id":2,"name":"Two","isComplete":false}]"""; + await VerifyResponseBodyAsync(httpContext, expectedBody); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs index 69bbec42a14f..ec80e7288d30 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs @@ -64,6 +64,11 @@ public class JsonTodoChild : JsonTodo public string? Child { get; set; } } +[JsonSerializable(typeof(Todo))] +[JsonSerializable(typeof(IAsyncEnumerable))] +public partial class SharedTestJsonContext : JsonSerializerContext +{ } + public class CustomFromBodyAttribute : Attribute, IFromBodyMetadata { public bool AllowEmpty { get; set; } diff --git a/src/Http/Http.Results/src/HttpResultsHelper.cs b/src/Http/Http.Results/src/HttpResultsHelper.cs index d3a23dcfa68c..93b70887912e 100644 --- a/src/Http/Http.Results/src/HttpResultsHelper.cs +++ b/src/Http/Http.Results/src/HttpResultsHelper.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -19,6 +20,9 @@ internal static partial class HttpResultsHelper internal const string DefaultContentType = "text/plain; charset=utf-8"; private static readonly Encoding DefaultEncoding = Encoding.UTF8; + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "The 'JsonSerializer.IsReflectionEnabledByDefault' feature switch, which is set to false by default for trimmed ASP.NET apps, ensures the JsonSerializer doesn't use Reflection.")] + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "See above.")] public static Task WriteResultAsJsonAsync( HttpContext httpContext, ILogger logger, @@ -34,8 +38,8 @@ public static Task WriteResultAsJsonAsync( jsonSerializerOptions ??= ResolveJsonOptions(httpContext).SerializerOptions; var jsonTypeInfo = (JsonTypeInfo)jsonSerializerOptions.GetTypeInfo(typeof(TValue)); - Type? runtimeType; - if (jsonTypeInfo.IsValid(runtimeType = value.GetType())) + Type? runtimeType = value.GetType(); + if (jsonTypeInfo.ShouldUseWith(runtimeType)) { Log.WritingResultAsJson(logger, jsonTypeInfo.Type.Name); return httpContext.Response.WriteAsJsonAsync( @@ -46,14 +50,14 @@ public static Task WriteResultAsJsonAsync( Log.WritingResultAsJson(logger, runtimeType.Name); // Since we don't know the type's polymorphic characteristics - // our best option is use the runtime type, so, - // call WriteAsJsonAsync() with the runtime type to serialize the runtime type rather than the declared type + // our best option is to serialize the value as 'object'. + // call WriteAsJsonAsync() rather than the declared type // and avoid source generators issues. // https://github.com/dotnet/aspnetcore/issues/43894 // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism - return httpContext.Response.WriteAsJsonAsync( + return httpContext.Response.WriteAsJsonAsync( value, - jsonSerializerOptions.GetTypeInfo(runtimeType), + jsonSerializerOptions, contentType: contentType); } diff --git a/src/Http/Http.Results/test/HttpResultsHelperTests.cs b/src/Http/Http.Results/test/HttpResultsHelperTests.cs index 3c2531f57b51..649a6bb63c3a 100644 --- a/src/Http/Http.Results/test/HttpResultsHelperTests.cs +++ b/src/Http/Http.Results/test/HttpResultsHelperTests.cs @@ -177,6 +177,58 @@ public async Task WriteResultAsJsonAsync_Works_UsingBaseType_ForChildTypes_WithJ Assert.Equal("With type hierarchies!", body!.Child); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WriteResultAsJsonAsync_Works_UsingUnspeakableType(bool useJsonContext) + { + // Arrange + var value = GetTodosAsync(); + var responseBodyStream = new MemoryStream(); + var httpContext = CreateHttpContext(responseBodyStream); + var serializerOptions = new JsonOptions().SerializerOptions; + + if (useJsonContext) + { + serializerOptions.TypeInfoResolver = TestJsonContext.Default; + } + + // Act + await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value, jsonSerializerOptions: serializerOptions); + + // Assert + var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), serializerOptions); + + Assert.Equal(3, body.Length); + + var one = Assert.IsType(body[0]); + Assert.Equal(1, one.Id); + Assert.True(one.IsComplete); + Assert.Equal("One", one.Name); + + var two = Assert.IsType(body[1]); + Assert.Equal(2, two.Id); + Assert.False(two.IsComplete); + Assert.Equal("Two", two.Name); + + var three = Assert.IsType(body[2]); + Assert.Equal(3, three.Id); + Assert.True(three.IsComplete); + Assert.Equal("Three", three.Name); + Assert.Equal("ThreeChild", three.Child); + } + + private static async IAsyncEnumerable GetTodosAsync() + { + yield return new JsonTodo() { Id = 1, IsComplete = true, Name = "One" }; + + // ensure this is async + await Task.Yield(); + + yield return new JsonTodo() { Id = 2, IsComplete = false, Name = "Two" }; + yield return new TodoJsonChild() { Id = 3, IsComplete = true, Name = "Three", Child = "ThreeChild" }; + } + private static DefaultHttpContext CreateHttpContext(Stream stream) => new() { @@ -198,6 +250,8 @@ private static IServiceProvider CreateServices() [JsonSerializable(typeof(TodoChild))] [JsonSerializable(typeof(JsonTodo))] [JsonSerializable(typeof(TodoStruct))] + [JsonSerializable(typeof(IAsyncEnumerable))] + [JsonSerializable(typeof(JsonTodo[]))] private partial class TestJsonContext : JsonSerializerContext { } @@ -224,7 +278,7 @@ private class TodoChild : Todo public string Child { get; set; } } - [JsonDerivedType(typeof(TodoJsonChild))] + [JsonDerivedType(typeof(TodoJsonChild), nameof(TodoJsonChild))] private class JsonTodo : Todo { } diff --git a/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs b/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs index 991d9aa0f337..fed3a1a69785 100644 --- a/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs +++ b/src/Http/Routing/src/RequestDelegateFilterPipelineBuilder.cs @@ -56,7 +56,7 @@ public static RequestDelegate Create(RequestDelegate requestDelegate, RequestDel var obj = await filteredInvocation(new DefaultEndpointFilterInvocationContext(httpContext, new object[] { httpContext })); if (obj is not null) { - await ExecuteHandlerHelper.ExecuteReturnAsync(obj, httpContext, jsonSerializerOptions, jsonTypeInfo); + await ExecuteHandlerHelper.ExecuteReturnAsync(obj, httpContext, jsonTypeInfo); } }; } diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs index b0f68a771377..273b11d6f65d 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs @@ -65,27 +65,38 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon var httpContext = context.HttpContext; - var runtimeType = context.Object?.GetType(); + // context.ObjectType reflects the declared model type when specified. + // For polymorphic scenarios where the user declares a return type, but returns a derived type, + // we want to serialize all the properties on the derived type. This keeps parity with + // the behavior you get when the user does not declare the return type. + // To enable this our best option is to check if the JsonTypeInfo for the declared type is valid, + // if it is use it. If it isn't, serialize the value as 'object' and let JsonSerializer serialize it as necessary. JsonTypeInfo? jsonTypeInfo = null; - if (context.ObjectType is not null) { var declaredTypeJsonInfo = SerializerOptions.GetTypeInfo(context.ObjectType); - if (declaredTypeJsonInfo.IsValid(runtimeType)) + var runtimeType = context.Object?.GetType(); + if (declaredTypeJsonInfo.ShouldUseWith(runtimeType)) { jsonTypeInfo = declaredTypeJsonInfo; } } - jsonTypeInfo ??= SerializerOptions.GetTypeInfo(runtimeType ?? typeof(object)); - var responseStream = httpContext.Response.Body; if (selectedEncoding.CodePage == Encoding.UTF8.CodePage) { try { - await JsonSerializer.SerializeAsync(responseStream, context.Object, jsonTypeInfo, httpContext.RequestAborted); + if (jsonTypeInfo is not null) + { + await JsonSerializer.SerializeAsync(responseStream, context.Object, jsonTypeInfo, httpContext.RequestAborted); + } + else + { + await JsonSerializer.SerializeAsync(responseStream, context.Object, SerializerOptions, httpContext.RequestAborted); + } + await responseStream.FlushAsync(httpContext.RequestAborted); } catch (OperationCanceledException) when (context.HttpContext.RequestAborted.IsCancellationRequested) { } @@ -99,7 +110,15 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon ExceptionDispatchInfo? exceptionDispatchInfo = null; try { - await JsonSerializer.SerializeAsync(transcodingStream, context.Object, jsonTypeInfo); + if (jsonTypeInfo is not null) + { + await JsonSerializer.SerializeAsync(transcodingStream, context.Object, jsonTypeInfo); + } + else + { + await JsonSerializer.SerializeAsync(transcodingStream, context.Object, SerializerOptions); + } + await transcodingStream.FlushAsync(); } catch (Exception ex) diff --git a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs index d7fc88d828e9..203f03590831 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs @@ -4,16 +4,14 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc.Formatters; -public class SystemTextJsonOutputFormatterTest : JsonOutputFormatterTestBase +public partial class SystemTextJsonOutputFormatterTest : JsonOutputFormatterTestBase { protected override TextOutputFormatter GetOutputFormatter() { @@ -161,15 +159,22 @@ async IAsyncEnumerable AsyncEnumerableClosedConnection([EnumeratorCancellat } } - [Fact] - public async Task WriteResponseBodyAsync_UsesJsonPolymorphismOptions() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WriteResponseBodyAsync_UsesJsonPolymorphismOptions(bool useJsonContext) { // Arrange var jsonOptions = new JsonOptions(); + if (useJsonContext) + { + jsonOptions.JsonSerializerOptions.TypeInfoResolver = TestJsonContext.Default; + } + var formatter = SystemTextJsonOutputFormatter.CreateFormatter(jsonOptions); var expectedContent = "{\"$type\":\"JsonPersonExtended\",\"age\":99,\"name\":\"Person\",\"child\":null,\"parent\":null}"; - JsonPerson todo = new JsonPersonExtended() + JsonPerson person = new JsonPersonExtended() { Name = "Person", Age = 99, @@ -185,7 +190,7 @@ public async Task WriteResponseBodyAsync_UsesJsonPolymorphismOptions() actionContext.HttpContext, new TestHttpResponseStreamWriterFactory().CreateWriter, typeof(JsonPerson), - todo) + person) { ContentType = new StringSegment(mediaType.ToString()), }; @@ -198,6 +203,56 @@ public async Task WriteResponseBodyAsync_UsesJsonPolymorphismOptions() Assert.Equal(expectedContent, actualContent); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WriteResponseBodyAsync_UsesJsonPolymorphismOptions_WithUnspeakableTypes(bool useJsonContext) + { + // Arrange + var jsonOptions = new JsonOptions(); + + if (useJsonContext) + { + jsonOptions.JsonSerializerOptions.TypeInfoResolver = TestJsonContext.Default; + } + + var formatter = SystemTextJsonOutputFormatter.CreateFormatter(jsonOptions); + var expectedContent = """[{"name":"One","child":null,"parent":null},{"$type":"JsonPersonExtended","age":99,"name":"Two","child":null,"parent":null}]"""; + var people = GetPeopleAsync(); + + var mediaType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); + var encoding = CreateOrGetSupportedEncoding(formatter, "utf-8", isDefaultEncoding: true); + + var body = new MemoryStream(); + var actionContext = GetActionContext(mediaType, body); + + var outputFormatterContext = new OutputFormatterWriteContext( + actionContext.HttpContext, + new TestHttpResponseStreamWriterFactory().CreateWriter, + typeof(IAsyncEnumerable), + people) + { + ContentType = new StringSegment(mediaType.ToString()), + }; + + // Act + await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-8")); + + // Assert + var actualContent = encoding.GetString(body.ToArray()); + Assert.Equal(expectedContent, actualContent); + } + + private static async IAsyncEnumerable GetPeopleAsync() + { + yield return new JsonPerson() { Name = "One" }; + + // ensure this is async + await Task.Yield(); + + yield return new JsonPersonExtended() { Name = "Two", Age = 99 }; + } + [Fact] public void WriteResponseBodyAsync_Throws_WhenTypeResolverIsNull() { @@ -220,18 +275,21 @@ private class Person [JsonPolymorphic] [JsonDerivedType(typeof(JsonPersonExtended), nameof(JsonPersonExtended))] private class JsonPerson : Person - {} + { } private class JsonPersonExtended : JsonPerson { public int Age { get; set; } } + [JsonSerializable(typeof(JsonPerson))] + [JsonSerializable(typeof(IAsyncEnumerable))] + private partial class TestJsonContext : JsonSerializerContext + { } + [JsonConverter(typeof(ThrowingFormatterPersonConverter))] private class ThrowingFormatterModel - { - - } + { } private class ThrowingFormatterPersonConverter : JsonConverter { diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonOutputFormatterController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonOutputFormatterController.cs index 7a129d820ea7..9b1a74f4a3e2 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonOutputFormatterController.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonOutputFormatterController.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Mvc; @@ -52,7 +52,7 @@ public ActionResult LargeObjectResult() => }; [HttpGet] - public ActionResult PolymorphicResult() => new DeriviedModel + public ActionResult PolymorphicResult() => new DerivedModel { Id = 10, Name = "test", @@ -71,7 +71,7 @@ public class SimpleModel public string StreetName { get; set; } } - public class DeriviedModel : SimpleModel + public class DerivedModel : SimpleModel { public string Address { get; set; } } diff --git a/src/Shared/Json/JsonSerializerExtensions.cs b/src/Shared/Json/JsonSerializerExtensions.cs index 0c0638dc4675..7068649a63f5 100644 --- a/src/Shared/Json/JsonSerializerExtensions.cs +++ b/src/Shared/Json/JsonSerializerExtensions.cs @@ -13,7 +13,7 @@ internal static class JsonSerializerExtensions public static bool HasKnownPolymorphism(this JsonTypeInfo jsonTypeInfo) => jsonTypeInfo.Type.IsSealed || jsonTypeInfo.Type.IsValueType || jsonTypeInfo.PolymorphismOptions is not null; - public static bool IsValid(this JsonTypeInfo jsonTypeInfo, [NotNullWhen(false)] Type? runtimeType) + public static bool ShouldUseWith(this JsonTypeInfo jsonTypeInfo, [NotNullWhen(false)] Type? runtimeType) => runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.HasKnownPolymorphism(); public static JsonTypeInfo GetReadOnlyTypeInfo(this JsonSerializerOptions options, Type type) diff --git a/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs b/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs index 12217930d751..6d3363fd56d0 100644 --- a/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs +++ b/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs @@ -1,15 +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 Microsoft.AspNetCore.Http; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization.Metadata; -using System.Text.Json; +using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Internal; internal static class ExecuteHandlerHelper { - public static Task ExecuteReturnAsync(object obj, HttpContext httpContext, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + public static Task ExecuteReturnAsync(object obj, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { // Terminal built ins if (obj is IResult result) @@ -24,7 +24,7 @@ public static Task ExecuteReturnAsync(object obj, HttpContext httpContext, JsonS else { // Otherwise, we JSON serialize when we reach the terminal state - return WriteJsonResponseAsync(httpContext.Response, obj, options, jsonTypeInfo); + return WriteJsonResponseAsync(httpContext.Response, obj, jsonTypeInfo); } } @@ -33,22 +33,26 @@ public static void SetPlaintextContentType(HttpContext httpContext) httpContext.Response.ContentType ??= "text/plain; charset=utf-8"; } - public static Task WriteJsonResponseAsync(HttpResponse response, T? value, JsonSerializerOptions options, JsonTypeInfo jsonTypeInfo) + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "The 'JsonSerializer.IsReflectionEnabledByDefault' feature switch, which is set to false by default for trimmed ASP.NET apps, ensures the JsonSerializer doesn't use Reflection.")] + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "See above.")] + public static Task WriteJsonResponseAsync(HttpResponse response, T? value, JsonTypeInfo jsonTypeInfo) { var runtimeType = value?.GetType(); - if (jsonTypeInfo.IsValid(runtimeType)) + if (jsonTypeInfo.ShouldUseWith(runtimeType)) { // In this case the polymorphism is not // relevant for us and will be handled by STJ, if needed. return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, jsonTypeInfo, default); } - // Call WriteAsJsonAsync() with the runtime type to serialize the runtime type rather than the declared type + // Since we don't know the type's polymorphic characteristics + // our best option is to serialize the value as 'object'. + // call WriteAsJsonAsync() rather than the declared type // and avoid source generators issues. // https://github.com/dotnet/aspnetcore/issues/43894 // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism - var runtimeTypeInfo = options.GetTypeInfo(runtimeType); - return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, runtimeTypeInfo, default); + return response.WriteAsJsonAsync(value, jsonTypeInfo.Options); } }