diff --git a/src/Http/Http.Extensions/src/JsonOptions.cs b/src/Http/Http.Extensions/src/JsonOptions.cs index 220ad7938f53..ad718418c09e 100644 --- a/src/Http/Http.Extensions/src/JsonOptions.cs +++ b/src/Http/Http.Extensions/src/JsonOptions.cs @@ -3,6 +3,7 @@ using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; #nullable enable @@ -21,6 +22,11 @@ public class JsonOptions // Because these options are for producing content that is written directly to the request // (and not embedded in an HTML page for example), we can use UnsafeRelaxedJsonEscaping. Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + + // The JsonSerializerOptions.GetTypeInfo method is called directly and needs a defined resolver + // setting the default resolver (reflection-based) but the user can overwrite it directly or calling + // .AddContext() + TypeInfoResolver = CreateDefaultTypeResolver() }; // Use a copy so the defaults are not modified. @@ -28,4 +34,11 @@ public class JsonOptions /// Gets the . /// public JsonSerializerOptions SerializerOptions { get; } = new JsonSerializerOptions(DefaultSerializerOptions); + +#pragma warning disable IL2026 // Suppressed in Microsoft.AspNetCore.Http.Extensions.WarningSuppressions.xml +#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling. + private static IJsonTypeInfoResolver CreateDefaultTypeResolver() + => new DefaultJsonTypeInfoResolver(); +#pragma warning restore IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling. +#pragma warning restore IL2026 // Suppressed in Microsoft.AspNetCore.Http.Extensions.WarningSuppressions.xml } diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.WarningSuppressions.xml b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.WarningSuppressions.xml new file mode 100644 index 000000000000..70f9e571648c --- /dev/null +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.WarningSuppressions.xml @@ -0,0 +1,12 @@ + + + + + ILLink + IL2026 + member + M:Microsoft.AspNetCore.Http.Json.JsonOptions.CreateDefaultTypeResolver + This warning is left in the product so developers get an ILLink warning when trimming an app, in future, only when Microsoft.AspNetCore.EnsureJsonTrimmability=false. + + + diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj index 6c7b5a47df8a..748965a360a6 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -21,7 +21,8 @@ - + + diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index f3c60b48473d..1bb30339b267 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -12,6 +12,7 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Json; @@ -37,10 +38,12 @@ public static partial class RequestDelegateFactory private static readonly MethodInfo ExecuteTaskWithEmptyResultMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteTaskWithEmptyResult), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteValueTaskWithEmptyResultMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteValueTaskWithEmptyResult), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteTaskOfTMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteTaskOfT), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo ExecuteTaskOfTFastMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteTaskOfTFast), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteTaskOfObjectMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteTaskOfObject), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteValueTaskOfObjectMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteValueTaskOfObject), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteTaskOfStringMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteTaskOfString), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteValueTaskOfTMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteValueTaskOfT), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo ExecuteValueTaskOfTFastMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteValueTaskOfTFast), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteValueTaskMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteValueTask), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteValueTaskOfStringMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteValueTaskOfString), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ExecuteTaskResultOfTMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteTaskResult), BindingFlags.NonPublic | BindingFlags.Static)!; @@ -62,8 +65,8 @@ public static partial class RequestDelegateFactory private static readonly PropertyInfo FormFilesIndexerProperty = typeof(IFormFileCollection).GetProperty("Item")!; private static readonly PropertyInfo FormIndexerProperty = typeof(IFormCollection).GetProperty("Item")!; - private static readonly MethodInfo JsonResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WriteJsonResponse), BindingFlags.NonPublic | BindingFlags.Static)!; - private static readonly MethodInfo JsonResultWriteResponseOfTAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WriteJsonResponseOfT), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo JsonResultWriteResponseOfTFastAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WriteJsonResponseFast), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo JsonResultWriteResponseOfTAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WriteJsonResponse), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo LogParameterBindingFailedMethod = GetMethodInfo>((httpContext, parameterType, parameterName, sourceValue, shouldThrow) => Log.ParameterBindingFailed(httpContext, parameterType, parameterName, sourceValue, shouldThrow)); @@ -998,19 +1001,28 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, } else if (returnType == typeof(object)) { - return Expression.Call(ExecuteAwaitedReturnMethod, methodCall, HttpContextExpr, factoryContext.JsonSerializerOptionsExpression); + return Expression.Call( + ExecuteAwaitedReturnMethod, + methodCall, + HttpContextExpr, + factoryContext.JsonSerializerOptionsExpression, + Expression.Constant(factoryContext.JsonSerializerOptions?.GetReadOnlyTypeInfo(typeof(object)), typeof(JsonTypeInfo))); } else if (returnType == typeof(ValueTask)) { return Expression.Call(ExecuteValueTaskOfObjectMethod, methodCall, - HttpContextExpr, factoryContext.JsonSerializerOptionsExpression); + HttpContextExpr, + factoryContext.JsonSerializerOptionsExpression, + Expression.Constant(factoryContext.JsonSerializerOptions?.GetReadOnlyTypeInfo(typeof(object)), typeof(JsonTypeInfo))); } else if (returnType == typeof(Task)) { return Expression.Call(ExecuteTaskOfObjectMethod, methodCall, - HttpContextExpr, factoryContext.JsonSerializerOptionsExpression); + HttpContextExpr, + factoryContext.JsonSerializerOptionsExpression, + Expression.Constant(factoryContext.JsonSerializerOptions?.GetReadOnlyTypeInfo(typeof(object)), typeof(JsonTypeInfo))); } else if (AwaitableInfo.IsTypeAwaitable(returnType, out _)) { @@ -1046,11 +1058,23 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, } else { + var jsonTypeInfo = factoryContext.JsonSerializerOptions?.GetReadOnlyTypeInfo(typeArg); + + if (jsonTypeInfo?.IsPolymorphicSafe() == true) + { + return Expression.Call( + ExecuteTaskOfTFastMethod.MakeGenericMethod(typeArg), + methodCall, + HttpContextExpr, + Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(typeArg))); + } + return Expression.Call( ExecuteTaskOfTMethod.MakeGenericMethod(typeArg), methodCall, HttpContextExpr, - factoryContext.JsonSerializerOptionsExpression); + factoryContext.JsonSerializerOptionsExpression, + Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(typeArg))); } } else if (returnType.IsGenericType && @@ -1075,11 +1099,23 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, } else { + var jsonTypeInfo = factoryContext.JsonSerializerOptions?.GetReadOnlyTypeInfo(typeArg); + + if (jsonTypeInfo?.IsPolymorphicSafe() == true) + { + return Expression.Call( + ExecuteValueTaskOfTFastMethod.MakeGenericMethod(typeArg), + methodCall, + HttpContextExpr, + Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(typeArg))); + } + return Expression.Call( ExecuteValueTaskOfTMethod.MakeGenericMethod(typeArg), methodCall, HttpContextExpr, - factoryContext.JsonSerializerOptionsExpression); + factoryContext.JsonSerializerOptionsExpression, + Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(typeArg))); } } else @@ -1105,14 +1141,26 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, { throw GetUnsupportedReturnTypeException(returnType); } - else if (returnType.IsValueType) - { - return Expression.Call(JsonResultWriteResponseOfTAsyncMethod.MakeGenericMethod(returnType), - HttpResponseExpr, methodCall, factoryContext.JsonSerializerOptionsExpression); - } else { - return Expression.Call(JsonResultWriteResponseAsyncMethod, HttpResponseExpr, methodCall, factoryContext.JsonSerializerOptionsExpression); + var jsonTypeInfo = factoryContext.JsonSerializerOptions?.GetReadOnlyTypeInfo(returnType); + + if (jsonTypeInfo?.IsPolymorphicSafe() == true) + { + return Expression.Call( + JsonResultWriteResponseOfTFastAsyncMethod.MakeGenericMethod(returnType), + HttpResponseExpr, + methodCall, + Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(returnType))); + + } + + return Expression.Call( + JsonResultWriteResponseOfTAsyncMethod.MakeGenericMethod(returnType), + HttpResponseExpr, + methodCall, + factoryContext.JsonSerializerOptionsExpression, + Expression.Constant(jsonTypeInfo, typeof(JsonTypeInfo<>).MakeGenericType(returnType))); } } @@ -2049,37 +2097,37 @@ 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) + private static Task ExecuteValueTaskOfObject(ValueTask valueTask, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo? jsonTypeInfo) { - static async Task ExecuteAwaited(ValueTask valueTask, HttpContext httpContext, JsonSerializerOptions? options) + static async Task ExecuteAwaited(ValueTask valueTask, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo? jsonTypeInfo) { - await ExecuteAwaitedReturn(await valueTask, httpContext, options); + await ExecuteAwaitedReturn(await valueTask, httpContext, options, jsonTypeInfo); } if (valueTask.IsCompletedSuccessfully) { - return ExecuteAwaitedReturn(valueTask.GetAwaiter().GetResult(), httpContext, options); + return ExecuteAwaitedReturn(valueTask.GetAwaiter().GetResult(), httpContext, options, jsonTypeInfo); } - return ExecuteAwaited(valueTask, httpContext, options); + return ExecuteAwaited(valueTask, httpContext, options, jsonTypeInfo); } - private static Task ExecuteTaskOfObject(Task task, HttpContext httpContext, JsonSerializerOptions? options) + private static Task ExecuteTaskOfObject(Task task, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo? jsonTypeInfo) { - static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonSerializerOptions? options) + static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo? jsonTypeInfo) { - await ExecuteAwaitedReturn(await task, httpContext, options); + await ExecuteAwaitedReturn(await task, httpContext, options, jsonTypeInfo); } if (task.IsCompletedSuccessfully) { - return ExecuteAwaitedReturn(task.GetAwaiter().GetResult(), httpContext, options); + return ExecuteAwaitedReturn(task.GetAwaiter().GetResult(), httpContext, options, jsonTypeInfo); } - return ExecuteAwaited(task, httpContext, options); + return ExecuteAwaited(task, httpContext, options, jsonTypeInfo); } - private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext, JsonSerializerOptions? options) + private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo? jsonTypeInfo) { // Terminal built ins if (obj is IResult result) @@ -2094,25 +2142,42 @@ private static Task ExecuteAwaitedReturn(object obj, HttpContext httpContext, Js else { // Otherwise, we JSON serialize when we reach the terminal state - return WriteJsonResponse(httpContext.Response, obj, options); + return WriteJsonResponse(httpContext.Response, obj, options, jsonTypeInfo); } } - private static Task ExecuteTaskOfT(Task task, HttpContext httpContext, JsonSerializerOptions? options) + private static Task ExecuteTaskOfTFast(Task task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { EnsureRequestTaskNotNull(task); - static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonSerializerOptions? options) + static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) { - await WriteJsonResponse(httpContext.Response, await task, options); + await WriteJsonResponseFast(httpContext.Response, await task, jsonTypeInfo); } if (task.IsCompletedSuccessfully) { - return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult(), options); + return WriteJsonResponseFast(httpContext.Response, task.GetAwaiter().GetResult(), jsonTypeInfo); } - return ExecuteAwaited(task, httpContext, options); + return ExecuteAwaited(task, httpContext, jsonTypeInfo); + } + + private static Task ExecuteTaskOfT(Task task, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo jsonTypeInfo) + { + EnsureRequestTaskNotNull(task); + + static async Task ExecuteAwaited(Task task, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo jsonTypeInfo) + { + await WriteJsonResponse(httpContext.Response, await task, options, jsonTypeInfo); + } + + if (task.IsCompletedSuccessfully) + { + return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult(), options, jsonTypeInfo); + } + + return ExecuteAwaited(task, httpContext, options, jsonTypeInfo); } private static Task ExecuteTaskOfString(Task task, HttpContext httpContext) @@ -2188,19 +2253,34 @@ static async Task ExecuteAwaited(ValueTask task) return ExecuteAwaited(valueTask); } - private static Task ExecuteValueTaskOfT(ValueTask task, HttpContext httpContext, JsonSerializerOptions? options) + private static Task ExecuteValueTaskOfTFast(ValueTask task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) + { + static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext, JsonTypeInfo jsonTypeInfo) + { + await WriteJsonResponseFast(httpContext.Response, await task, jsonTypeInfo); + } + + if (task.IsCompletedSuccessfully) + { + return WriteJsonResponseFast(httpContext.Response, task.GetAwaiter().GetResult(), jsonTypeInfo); + } + + return ExecuteAwaited(task, httpContext, jsonTypeInfo); + } + + private static Task ExecuteValueTaskOfT(ValueTask task, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo jsonTypeInfo) { - static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext, JsonSerializerOptions? options) + static async Task ExecuteAwaited(ValueTask task, HttpContext httpContext, JsonSerializerOptions? options, JsonTypeInfo jsonTypeInfo) { - await WriteJsonResponse(httpContext.Response, await task, options); + await WriteJsonResponse(httpContext.Response, await task, options, jsonTypeInfo); } if (task.IsCompletedSuccessfully) { - return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult(), options); + return WriteJsonResponse(httpContext.Response, task.GetAwaiter().GetResult(), options, jsonTypeInfo); } - return ExecuteAwaited(task, httpContext, options); + return ExecuteAwaited(task, httpContext, options, jsonTypeInfo); } private static Task ExecuteValueTaskOfString(ValueTask task, HttpContext httpContext) @@ -2247,20 +2327,34 @@ private static async Task ExecuteResultWriteResponse(IResult? result, HttpContex await EnsureRequestResultNotNull(result).ExecuteAsync(httpContext); } - private static Task WriteJsonResponse(HttpResponse response, object? value, JsonSerializerOptions? options) + // This method will not check for polymorphism and will + // leverage the STJ polymorphism support. + 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) { + var runtimeType = value?.GetType(); + + // Edge case but possible if the RequestDelegateFactoryOptions.ServiceProvider and + // RequestDelegateFactoryOptions.EndpointBuilder.ServiceProvider are null + // In this situation both options and jsonTypeInfo are null. + options ??= response.HttpContext.RequestServices.GetService>()?.Value.SerializerOptions ?? JsonOptions.DefaultSerializerOptions; + jsonTypeInfo ??= (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + + if (runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.IsPolymorphicSafe()) + { + // 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 // and avoid source generators issues. // https://github.com/dotnet/aspnetcore/issues/43894 // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism - return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, value is null ? typeof(object) : value.GetType(), options, default); - } - - // Only for use with structs, use WriteJsonResponse for classes to preserve polymorphism - private static Task WriteJsonResponseOfT(HttpResponse response, T value, JsonSerializerOptions? options) - { - Debug.Assert(typeof(T).IsValueType); - return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, options, default); + var runtimeTypeInfo = options.GetTypeInfo(runtimeType); + return HttpResponseJsonExtensions.WriteAsJsonAsync(response, value!, runtimeTypeInfo, default); } private static NotSupportedException GetUnsupportedReturnTypeException(Type returnType) diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index ba833e414546..1d7b68c6af16 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -17,7 +17,9 @@ using System.Security.Cryptography.X509Certificates; 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.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -31,6 +33,7 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Moq; +using Xunit.Abstractions; namespace Microsoft.AspNetCore.Routing.Internal; @@ -3070,6 +3073,122 @@ public async Task RequestDelegateWritesMembersFromChildTypesToJsonResponseBody(D Assert.Equal("With type hierarchies!", deserializedResponseBody!.Child); } + public static IEnumerable PolymorphicResult + { + get + { + JsonTodoChild originalTodo = new() + { + Name = "Write even more tests!", + Child = "With type hierarchies!", + }; + + JsonTodo TestAction() => originalTodo; + + Task TaskTestAction() => Task.FromResult(originalTodo); + async Task TaskTestActionAwaited() + { + await Task.Yield(); + return originalTodo; + } + + ValueTask ValueTaskTestAction() => ValueTask.FromResult(originalTodo); + async ValueTask ValueTaskTestActionAwaited() + { + await Task.Yield(); + return originalTodo; + } + + return new List + { + new object[] { (Func)TestAction }, + new object[] { (Func>)TaskTestAction}, + new object[] { (Func>)TaskTestActionAwaited}, + new object[] { (Func>)ValueTaskTestAction}, + new object[] { (Func>)ValueTaskTestActionAwaited}, + }; + } + } + + [Theory] + [MemberData(nameof(PolymorphicResult))] + public async Task RequestDelegateWritesMembersFromChildTypesToJsonResponseBody_WithJsonPolymorphicOptions(Delegate @delegate) + { + var httpContext = CreateHttpContext(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(LoggerFactory) + .AddSingleton(Options.Create(new JsonOptions())) + .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); + Assert.Equal("With type hierarchies!", deserializedResponseBody!.Child); + } + + [Theory] + [MemberData(nameof(PolymorphicResult))] + public async Task RequestDelegateWritesMembersFromChildTypesToJsonResponseBody_WithJsonPolymorphicOptionsAndConfiguredJsonOptions(Delegate @delegate) + { + var httpContext = CreateHttpContext(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(LoggerFactory) + .AddSingleton(Options.Create(new JsonOptions())) + .BuildServiceProvider(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + var factoryResult = RequestDelegateFactory.Create(@delegate, new RequestDelegateFactoryOptions { ServiceProvider = httpContext.RequestServices }); + 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); + Assert.Equal("With type hierarchies!", deserializedResponseBody!.Child); + } + + [Theory] + [MemberData(nameof(PolymorphicResult))] + public async Task RequestDelegateWritesJsonTypeDiscriminatorToJsonResponseBody_WithJsonPolymorphicOptions(Delegate @delegate) + { + var httpContext = CreateHttpContext(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(LoggerFactory) + .AddSingleton(Options.Create(new JsonOptions())) + .BuildServiceProvider(); + + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + var factoryResult = RequestDelegateFactory.Create(@delegate); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + var deserializedResponseBody = JsonNode.Parse(responseBodyStream.ToArray()); + + Assert.NotNull(deserializedResponseBody); + Assert.NotNull(deserializedResponseBody["$type"]); + Assert.Equal(nameof(JsonTodoChild), deserializedResponseBody["$type"]!.GetValue()); + } + public static IEnumerable JsonContextActions { get @@ -3110,6 +3229,22 @@ public async Task RequestDelegateWritesAsJsonResponseBody_WithJsonSerializerCont Assert.Equal("Write even more tests!", deserializedResponseBody!.Name); } + [Fact] + public void CreateDelegateThrows_WhenGetJsonTypeInfoFail() + { + var httpContext = CreateHttpContext(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(LoggerFactory) + .ConfigureHttpJsonOptions(o => o.SerializerOptions.AddContext()) + .BuildServiceProvider(); + + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + TodoStruct TestAction() => new TodoStruct(42, "Bob", true); + Assert.Throws(() => RequestDelegateFactory.Create(TestAction, new() { ServiceProvider = httpContext.RequestServices })); + } + public static IEnumerable CustomResults { get @@ -7526,6 +7661,11 @@ private class TodoChild : Todo public string? Child { get; set; } } + private class JsonTodoChild : JsonTodo + { + public string? Child { get; set; } + } + private class CustomTodo : Todo { public static async ValueTask BindAsync(HttpContext context, ParameterInfo parameter) @@ -7539,6 +7679,8 @@ private class CustomTodo : Todo } } + [JsonPolymorphic] + [JsonDerivedType(typeof(JsonTodoChild), nameof(JsonTodoChild))] private class JsonTodo : Todo { public static async ValueTask BindAsync(HttpContext context, ParameterInfo parameter) diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs index 54e14704e7e9..ef9308e5ff70 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs @@ -5,6 +5,8 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Mvc.Formatters; @@ -21,6 +23,8 @@ public SystemTextJsonOutputFormatter(JsonSerializerOptions jsonSerializerOptions { SerializerOptions = jsonSerializerOptions; + jsonSerializerOptions.MakeReadOnly(); + SupportedEncodings.Add(Encoding.UTF8); SupportedEncodings.Add(Encoding.Unicode); SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationJson); @@ -68,18 +72,27 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon var httpContext = context.HttpContext; - // 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 and with Json.Net at least at the top level. - var objectType = context.Object?.GetType() ?? context.ObjectType ?? typeof(object); + var runtimeType = context.Object?.GetType(); + JsonTypeInfo? jsonTypeInfo = null; + + if (context.ObjectType is not null) + { + var declaredTypeJsonInfo = SerializerOptions.GetTypeInfo(context.ObjectType); + + if (declaredTypeJsonInfo.IsPolymorphicSafe() || context.Object is null || runtimeType == declaredTypeJsonInfo.Type) + { + 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, objectType, SerializerOptions, httpContext.RequestAborted); + await JsonSerializer.SerializeAsync(responseStream, context.Object, jsonTypeInfo, httpContext.RequestAborted); await responseStream.FlushAsync(httpContext.RequestAborted); } catch (OperationCanceledException) when (context.HttpContext.RequestAborted.IsCancellationRequested) { } @@ -93,7 +106,7 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon ExceptionDispatchInfo? exceptionDispatchInfo = null; try { - await JsonSerializer.SerializeAsync(transcodingStream, context.Object, objectType, SerializerOptions); + await JsonSerializer.SerializeAsync(transcodingStream, context.Object, jsonTypeInfo); await transcodingStream.FlushAsync(); } catch (Exception ex) diff --git a/src/Mvc/Mvc.Core/src/JsonOptions.cs b/src/Mvc/Mvc.Core/src/JsonOptions.cs index d3966142f027..834c758bb9dc 100644 --- a/src/Mvc/Mvc.Core/src/JsonOptions.cs +++ b/src/Mvc/Mvc.Core/src/JsonOptions.cs @@ -2,6 +2,7 @@ // 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.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -37,5 +38,13 @@ public class JsonOptions // from deserialization errors that might occur from deeply nested objects. // This value is the same for model binding and Json.Net's serialization. MaxDepth = MvcOptions.DefaultMaxModelBindingRecursionDepth, + + // The JsonSerializerOptions.GetTypeInfo method is called directly and needs a defined resolver + // setting the default resolver (reflection-based) but the user can overwrite it directly or calling + // .AddContext() + TypeInfoResolver = CreateDefaultTypeResolver() }; + + private static IJsonTypeInfoResolver CreateDefaultTypeResolver() + => new DefaultJsonTypeInfoResolver(); } diff --git a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj index 36a376a97f50..477e4fb86aa1 100644 --- a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj @@ -33,6 +33,7 @@ Microsoft.AspNetCore.Mvc.RouteAttribute + diff --git a/src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs b/src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs index 1e8a0a0c666a..5fee454c939f 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs @@ -123,7 +123,7 @@ public async Task WriteResponseBodyAsync_Encodes() var outputFormatterContext = new OutputFormatterWriteContext( actionContext.HttpContext, new TestHttpResponseStreamWriterFactory().CreateWriter, - typeof(string), + typeof(object), content) { ContentType = new StringSegment(mediaType.ToString()), diff --git a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs index ccf182f6fa65..d7fc88d828e9 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs @@ -4,7 +4,9 @@ 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; @@ -22,8 +24,10 @@ protected override TextOutputFormatter GetOutputFormatter() public async Task WriteResponseBodyAsync_AllowsConfiguringPreserveReferenceHandling() { // Arrange - var formatter = GetOutputFormatter(); - ((SystemTextJsonOutputFormatter)formatter).SerializerOptions.ReferenceHandler = ReferenceHandler.Preserve; + var jsonOptions = new JsonOptions(); + jsonOptions.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve; + + var formatter = SystemTextJsonOutputFormatter.CreateFormatter(jsonOptions); var expectedContent = "{\"$id\":\"1\",\"name\":\"Person\",\"child\":{\"$id\":\"2\",\"name\":\"Child\",\"child\":null,\"parent\":{\"$ref\":\"1\"}},\"parent\":null}"; var person = new Person { @@ -157,6 +161,53 @@ async IAsyncEnumerable AsyncEnumerableClosedConnection([EnumeratorCancellat } } + [Fact] + public async Task WriteResponseBodyAsync_UsesJsonPolymorphismOptions() + { + // Arrange + var jsonOptions = new JsonOptions(); + + var formatter = SystemTextJsonOutputFormatter.CreateFormatter(jsonOptions); + var expectedContent = "{\"$type\":\"JsonPersonExtended\",\"age\":99,\"name\":\"Person\",\"child\":null,\"parent\":null}"; + JsonPerson todo = new JsonPersonExtended() + { + Name = "Person", + Age = 99, + }; + + 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(JsonPerson), + todo) + { + 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); + } + + [Fact] + public void WriteResponseBodyAsync_Throws_WhenTypeResolverIsNull() + { + // Arrange + var jsonOptions = new JsonOptions(); + jsonOptions.JsonSerializerOptions.TypeInfoResolver = null; + + Assert.Throws(() => SystemTextJsonOutputFormatter.CreateFormatter(jsonOptions)); + } + private class Person { public string Name { get; set; } @@ -166,6 +217,16 @@ private class Person public Person Parent { get; set; } } + [JsonPolymorphic] + [JsonDerivedType(typeof(JsonPersonExtended), nameof(JsonPersonExtended))] + private class JsonPerson : Person + {} + + private class JsonPersonExtended : JsonPerson + { + public int Age { get; set; } + } + [JsonConverter(typeof(ThrowingFormatterPersonConverter))] private class ThrowingFormatterModel { diff --git a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs index 9d333536a7d5..2067351335cd 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs @@ -56,4 +56,18 @@ static void ConfigureServices(IServiceCollection serviceCollection) [Fact] public override Task Formatting_PolymorphicModel() => base.Formatting_PolymorphicModel(); + + [Fact] + public async Task Formatting_PolymorphicModel_WithJsonPolymorphism() + { + // Arrange + var expected = "{\"$type\":\"DerivedModel\",\"address\":\"Some address\",\"id\":10,\"name\":\"test\",\"streetName\":null}"; + + // Act + var response = await Client.GetAsync($"/SystemTextJsonOutputFormatter/{nameof(SystemTextJsonOutputFormatterController.PolymorphicResult)}"); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + Assert.Equal(expected, await response.Content.ReadAsStringAsync()); + } } diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/SystemTextJsonOutputFormatterController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/SystemTextJsonOutputFormatterController.cs new file mode 100644 index 000000000000..287ffa90fd91 --- /dev/null +++ b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/SystemTextJsonOutputFormatterController.cs @@ -0,0 +1,37 @@ +// 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.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace FormatterWebSite.Controllers; + +[ApiController] +[Route("[controller]/[action]")] +[Produces("application/json")] +public class SystemTextJsonOutputFormatterController : ControllerBase +{ + [HttpGet] + public ActionResult PolymorphicResult() => new DerivedModel + { + Id = 10, + Name = "test", + Address = "Some address", + }; + + [JsonPolymorphic] + [JsonDerivedType(typeof(DerivedModel), nameof(DerivedModel))] + public class SimpleModel + { + public int Id { get; set; } + + public string Name { get; set; } + + public string StreetName { get; set; } + } + + public class DerivedModel : SimpleModel + { + public string Address { get; set; } + } +} diff --git a/src/Shared/Json/JsonSerializerExtensions.cs b/src/Shared/Json/JsonSerializerExtensions.cs new file mode 100644 index 000000000000..bf53e0f21993 --- /dev/null +++ b/src/Shared/Json/JsonSerializerExtensions.cs @@ -0,0 +1,19 @@ +// 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; + +namespace Microsoft.AspNetCore.Http; + +internal static class JsonSerializerExtensions +{ + public static bool IsPolymorphicSafe(this JsonTypeInfo jsonTypeInfo) + => jsonTypeInfo.Type.IsSealed || jsonTypeInfo.Type.IsValueType || jsonTypeInfo.PolymorphismOptions is not null; + + public static JsonTypeInfo GetReadOnlyTypeInfo(this JsonSerializerOptions options, Type type) + { + options.MakeReadOnly(); + return options.GetTypeInfo(type); + } +}