diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index 409daf83f94a..08f8bacf5a82 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -1055,7 +1055,7 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, { var jsonTypeInfo = factoryContext.JsonSerializerOptions.GetReadOnlyTypeInfo(typeArg); - if (jsonTypeInfo.IsPolymorphicSafe() == true) + if (jsonTypeInfo.HasKnownPolymorphism()) { return Expression.Call( ExecuteTaskOfTFastMethod.MakeGenericMethod(typeArg), @@ -1096,7 +1096,7 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, { var jsonTypeInfo = factoryContext.JsonSerializerOptions.GetReadOnlyTypeInfo(typeArg); - if (jsonTypeInfo.IsPolymorphicSafe() == true) + if (jsonTypeInfo.HasKnownPolymorphism()) { return Expression.Call( ExecuteValueTaskOfTFastMethod.MakeGenericMethod(typeArg), @@ -1140,7 +1140,7 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, { var jsonTypeInfo = factoryContext.JsonSerializerOptions.GetReadOnlyTypeInfo(returnType); - if (jsonTypeInfo.IsPolymorphicSafe() == true) + if (jsonTypeInfo.HasKnownPolymorphism()) { return Expression.Call( JsonResultWriteResponseOfTFastAsyncMethod.MakeGenericMethod(returnType), diff --git a/src/Http/Http.Results/src/HttpResultsHelper.cs b/src/Http/Http.Results/src/HttpResultsHelper.cs index 477f55290104..d3a23dcfa68c 100644 --- a/src/Http/Http.Results/src/HttpResultsHelper.cs +++ b/src/Http/Http.Results/src/HttpResultsHelper.cs @@ -1,12 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Http; @@ -16,13 +19,10 @@ internal static partial class HttpResultsHelper internal const string DefaultContentType = "text/plain; charset=utf-8"; private static readonly Encoding DefaultEncoding = Encoding.UTF8; - // Remove once https://github.com/dotnet/aspnetcore/pull/46008 is done. - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] - public static Task WriteResultAsJsonAsync( + public static Task WriteResultAsJsonAsync( HttpContext httpContext, ILogger logger, - T? value, + TValue? value, string? contentType = null, JsonSerializerOptions? jsonSerializerOptions = null) { @@ -31,32 +31,30 @@ public static Task WriteResultAsJsonAsync( return Task.CompletedTask; } - var declaredType = typeof(T); - if (declaredType.IsValueType) - { - Log.WritingResultAsJson(logger, declaredType.Name); + jsonSerializerOptions ??= ResolveJsonOptions(httpContext).SerializerOptions; + var jsonTypeInfo = (JsonTypeInfo)jsonSerializerOptions.GetTypeInfo(typeof(TValue)); - // In this case the polymorphism is not - // relevant and we don't need to box. + Type? runtimeType; + if (jsonTypeInfo.IsValid(runtimeType = value.GetType())) + { + Log.WritingResultAsJson(logger, jsonTypeInfo.Type.Name); return httpContext.Response.WriteAsJsonAsync( - value, - options: jsonSerializerOptions, - contentType: contentType); + value, + jsonTypeInfo, + contentType: contentType); } - var runtimeType = value.GetType(); - Log.WritingResultAsJson(logger, runtimeType.Name); - - // 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 use the runtime type, so, + // 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://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism return httpContext.Response.WriteAsJsonAsync( - value, - runtimeType, - options: jsonSerializerOptions, - contentType: contentType); + value, + jsonSerializerOptions.GetTypeInfo(runtimeType), + contentType: contentType); } public static Task WriteResultAsContentAsync( @@ -146,6 +144,12 @@ public static void ApplyProblemDetailsDefaultsIfNeeded(object? value, int? statu } } + private static JsonOptions ResolveJsonOptions(HttpContext httpContext) + { + // Attempt to resolve options from DI then fallback to default options + return httpContext.RequestServices.GetService>()?.Value ?? new JsonOptions(); + } + internal static partial class Log { [LoggerMessage(1, LogLevel.Information, diff --git a/src/Http/Http.Results/src/JsonHttpResultOfT.cs b/src/Http/Http.Results/src/JsonHttpResultOfT.cs index d743591d547f..4841e675ecfd 100644 --- a/src/Http/Http.Results/src/JsonHttpResultOfT.cs +++ b/src/Http/Http.Results/src/JsonHttpResultOfT.cs @@ -1,7 +1,9 @@ // 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.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -18,33 +20,33 @@ public sealed partial class JsonHttpResult : IResult, IStatusCodeHttpRes /// /// The value to format in the entity body. /// The serializer settings. - internal JsonHttpResult(TValue? value, JsonSerializerOptions? jsonSerializerOptions) - : this(value, statusCode: null, contentType: null, jsonSerializerOptions: jsonSerializerOptions) - { - } - - /// - /// Initializes a new instance of the class with the values. - /// - /// The value to format in the entity body. /// The HTTP status code of the response. - /// The serializer settings. - internal JsonHttpResult(TValue? value, int? statusCode, JsonSerializerOptions? jsonSerializerOptions) - : this(value, statusCode: statusCode, contentType: null, jsonSerializerOptions: jsonSerializerOptions) + /// The value for the Content-Type header + [RequiresDynamicCode(JsonHttpResultTrimmerWarning.SerializationRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(JsonHttpResultTrimmerWarning.SerializationUnreferencedCodeMessage)] + internal JsonHttpResult(TValue? value, JsonSerializerOptions? jsonSerializerOptions, int? statusCode = null, string? contentType = null) { + Value = value; + ContentType = contentType; + + if (value is ProblemDetails problemDetails) + { + ProblemDetailsDefaults.Apply(problemDetails, statusCode); + statusCode ??= problemDetails.Status; + } + StatusCode = statusCode; + + if (jsonSerializerOptions is not null && !jsonSerializerOptions.IsReadOnly) + { + jsonSerializerOptions.TypeInfoResolver ??= new DefaultJsonTypeInfoResolver(); + } + + JsonSerializerOptions = jsonSerializerOptions; } - /// - /// Initializes a new instance of the class with the values. - /// - /// The value to format in the entity body. - /// The HTTP status code of the response. - /// The serializer settings. - /// The value for the Content-Type header - internal JsonHttpResult(TValue? value, int? statusCode, string? contentType, JsonSerializerOptions? jsonSerializerOptions) + internal JsonHttpResult(TValue? value, int? statusCode = null, string? contentType = null) { Value = value; - JsonSerializerOptions = jsonSerializerOptions; ContentType = contentType; if (value is ProblemDetails problemDetails) @@ -59,7 +61,12 @@ internal JsonHttpResult(TValue? value, int? statusCode, string? contentType, Jso /// /// Gets or sets the serializer settings. /// - public JsonSerializerOptions? JsonSerializerOptions { get; internal init; } + public JsonSerializerOptions? JsonSerializerOptions { get; } + + /// + /// Gets or sets the serializer settings. + /// + internal JsonTypeInfo? JsonTypeInfo { get; init; } /// /// Gets the object result. @@ -71,7 +78,7 @@ internal JsonHttpResult(TValue? value, int? statusCode, string? contentType, Jso /// /// Gets the value for the Content-Type header. /// - public string? ContentType { get; internal set; } + public string? ContentType { get; } /// /// Gets the HTTP status code. @@ -93,6 +100,30 @@ public Task ExecuteAsync(HttpContext httpContext) httpContext.Response.StatusCode = statusCode; } + if (Value is null) + { + return Task.CompletedTask; + } + + if (JsonTypeInfo != null) + { + HttpResultsHelper.Log.WritingResultAsJson(logger, JsonTypeInfo.Type.Name); + + if (JsonTypeInfo is JsonTypeInfo typedJsonTypeInfo) + { + // We don't need to box here. + return httpContext.Response.WriteAsJsonAsync( + Value, + typedJsonTypeInfo, + contentType: ContentType); + } + + return httpContext.Response.WriteAsJsonAsync( + Value, + JsonTypeInfo, + contentType: ContentType); + } + return HttpResultsHelper.WriteResultAsJsonAsync( httpContext, logger, diff --git a/src/Http/Http.Results/src/JsonHttpResultTrimmerWarning.cs b/src/Http/Http.Results/src/JsonHttpResultTrimmerWarning.cs new file mode 100644 index 000000000000..e2120087ff24 --- /dev/null +++ b/src/Http/Http.Results/src/JsonHttpResultTrimmerWarning.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http; + +internal class JsonHttpResultTrimmerWarning +{ + public const string SerializationUnreferencedCodeMessage = "JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext."; + public const string SerializationRequiresDynamicCodeMessage = "JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use the overload that takes a JsonTypeInfo or JsonSerializerContext."; + +} diff --git a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj index 3b5c22a26f99..3473e085dfaf 100644 --- a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj +++ b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Http/Http.Results/src/PublicAPI.Unshipped.txt b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt index 157ef8226525..393991164c2b 100644 --- a/src/Http/Http.Results/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Results/src/PublicAPI.Unshipped.txt @@ -12,6 +12,10 @@ static Microsoft.AspNetCore.Http.Results.Created(string? uri, object? value) -> static Microsoft.AspNetCore.Http.Results.Created(System.Uri? uri, object? value) -> Microsoft.AspNetCore.Http.IResult! static Microsoft.AspNetCore.Http.Results.Created(string? uri, TValue? value) -> Microsoft.AspNetCore.Http.IResult! static Microsoft.AspNetCore.Http.Results.Created(System.Uri? uri, TValue? value) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Json(object? data, System.Text.Json.Serialization.Metadata.JsonTypeInfo! jsonTypeInfo, string? contentType = null, int? statusCode = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Json(object? data, System.Type! type, System.Text.Json.Serialization.JsonSerializerContext! context, string? contentType = null, int? statusCode = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Json(TValue? data, System.Text.Json.Serialization.JsonSerializerContext! context, string? contentType = null, int? statusCode = null) -> Microsoft.AspNetCore.Http.IResult! +static Microsoft.AspNetCore.Http.Results.Json(TValue? data, System.Text.Json.Serialization.Metadata.JsonTypeInfo! jsonTypeInfo, string? contentType = null, int? statusCode = null) -> Microsoft.AspNetCore.Http.IResult! static Microsoft.AspNetCore.Http.TypedResults.Created() -> Microsoft.AspNetCore.Http.HttpResults.Created! static Microsoft.AspNetCore.Http.TypedResults.Created(string? uri) -> Microsoft.AspNetCore.Http.HttpResults.Created! static Microsoft.AspNetCore.Http.TypedResults.Created(System.Uri? uri) -> Microsoft.AspNetCore.Http.HttpResults.Created! @@ -22,4 +26,6 @@ static Microsoft.AspNetCore.Http.TypedResults.Created(System.Uri? uri, T *REMOVED*static Microsoft.AspNetCore.Http.TypedResults.Created(System.Uri! uri) -> Microsoft.AspNetCore.Http.HttpResults.Created! *REMOVED*static Microsoft.AspNetCore.Http.TypedResults.Created(string! uri) -> Microsoft.AspNetCore.Http.HttpResults.Created! *REMOVED*static Microsoft.AspNetCore.Http.TypedResults.Created(System.Uri! uri, TValue? value) -> Microsoft.AspNetCore.Http.HttpResults.Created! -*REMOVED*static Microsoft.AspNetCore.Http.TypedResults.Created(string! uri, TValue? value) -> Microsoft.AspNetCore.Http.HttpResults.Created! \ No newline at end of file +*REMOVED*static Microsoft.AspNetCore.Http.TypedResults.Created(string! uri, TValue? value) -> Microsoft.AspNetCore.Http.HttpResults.Created! +static Microsoft.AspNetCore.Http.TypedResults.Json(TValue? data, System.Text.Json.Serialization.JsonSerializerContext! context, string? contentType = null, int? statusCode = null) -> Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult! +static Microsoft.AspNetCore.Http.TypedResults.Json(TValue? data, System.Text.Json.Serialization.Metadata.JsonTypeInfo! jsonTypeInfo, string? contentType = null, int? statusCode = null) -> Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult! diff --git a/src/Http/Http.Results/src/Results.cs b/src/Http/Http.Results/src/Results.cs index 6acc40b9e096..ab760c99f429 100644 --- a/src/Http/Http.Results/src/Results.cs +++ b/src/Http/Http.Results/src/Results.cs @@ -6,6 +6,8 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.HttpResults; @@ -181,9 +183,51 @@ public static IResult Content(string? content, MediaTypeHeaderValue contentType) /// as JSON format for the response. /// Callers should cache an instance of serializer settings to avoid /// recreating cached data with each call. + [RequiresUnreferencedCode(JsonHttpResultTrimmerWarning.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(JsonHttpResultTrimmerWarning.SerializationRequiresDynamicCodeMessage)] public static IResult Json(object? data, JsonSerializerOptions? options = null, string? contentType = null, int? statusCode = null) => Json(data, options, contentType, statusCode); + /// + /// Creates a that serializes the specified object to JSON. + /// + /// The object to write as JSON. + /// Metadata about the type to convert. + /// The content-type to set on the response. + /// The status code to set on the response. + /// The created that serializes the specified + /// as JSON format for the response. + /// Callers should cache an instance of serializer settings to avoid + /// recreating cached data with each call. +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static IResult Json(object? data, JsonTypeInfo jsonTypeInfo, string? contentType = null, int? statusCode = null) + { + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + return new JsonHttpResult(data, statusCode, contentType) { JsonTypeInfo = jsonTypeInfo }; + } + + /// + /// Creates a that serializes the specified object to JSON. + /// + /// The object to write as JSON. + /// The type of object to write. + /// A metadata provider for serializable types. + /// The content-type to set on the response. + /// The status code to set on the response. + /// The created that serializes the specified + /// as JSON format for the response. + /// Callers should cache an instance of serializer settings to avoid + /// recreating cached data with each call. +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static IResult Json(object? data, Type type, JsonSerializerContext context, string? contentType = null, int? statusCode = null) + { + ArgumentNullException.ThrowIfNull(context); + return new JsonHttpResult(data, statusCode, contentType) + { + JsonTypeInfo = context.GetRequiredTypeInfo(type) + }; + } + /// /// Creates a that serializes the specified object to JSON. /// @@ -195,11 +239,45 @@ public static IResult Json(object? data, JsonSerializerOptions? options = null, /// as JSON format for the response. /// Callers should cache an instance of serializer settings to avoid /// recreating cached data with each call. + [RequiresUnreferencedCode(JsonHttpResultTrimmerWarning.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(JsonHttpResultTrimmerWarning.SerializationRequiresDynamicCodeMessage)] #pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters public static IResult Json(TValue? data, JsonSerializerOptions? options = null, string? contentType = null, int? statusCode = null) #pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters => TypedResults.Json(data, options, contentType, statusCode); + /// + /// Creates a that serializes the specified object to JSON. + /// + /// The object to write as JSON. + /// Metadata about the type to convert. + /// The content-type to set on the response. + /// The status code to set on the response. + /// The created that serializes the specified + /// as JSON format for the response. + /// Callers should cache an instance of serializer settings to avoid + /// recreating cached data with each call. +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static IResult Json(TValue? data, JsonTypeInfo jsonTypeInfo, string? contentType = null, int? statusCode = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + => TypedResults.Json(data, jsonTypeInfo, contentType, statusCode); + + /// + /// Creates a that serializes the specified object to JSON. + /// + /// The object to write as JSON. + /// A metadata provider for serializable types. + /// The content-type to set on the response. + /// The status code to set on the response. + /// The created that serializes the specified + /// as JSON format for the response. + /// Callers should cache an instance of serializer settings to avoid + /// recreating cached data with each call. +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static IResult Json(TValue? data, JsonSerializerContext context, string? contentType = null, int? statusCode = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + => TypedResults.Json(data, context, contentType, statusCode); + /// /// Writes the byte-array content to the response. /// diff --git a/src/Http/Http.Results/src/TypedResults.cs b/src/Http/Http.Results/src/TypedResults.cs index 9093a346ec25..ceb2cea29bb2 100644 --- a/src/Http/Http.Results/src/TypedResults.cs +++ b/src/Http/Http.Results/src/TypedResults.cs @@ -6,6 +6,8 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.HttpResults; @@ -191,11 +193,49 @@ public static ContentHttpResult Content(string? content, MediaTypeHeaderValue co /// The status code to set on the response. /// The created that serializes the specified /// as JSON format for the response. + [RequiresUnreferencedCode(JsonHttpResultTrimmerWarning.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(JsonHttpResultTrimmerWarning.SerializationRequiresDynamicCodeMessage)] public static JsonHttpResult Json(TValue? data, JsonSerializerOptions? options = null, string? contentType = null, int? statusCode = null) - => new(data, statusCode, options) + => new(data, options, statusCode, contentType); + + /// + /// Creates a that serializes the specified object to JSON. + /// + /// The type of object that will be JSON serialized to the response body. + /// The object to write as JSON. + /// Metadata about the type to convert. + /// The content-type to set on the response. + /// The status code to set on the response. + /// The created that serializes the specified + /// as JSON format for the response. +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static JsonHttpResult Json(TValue? data, JsonTypeInfo jsonTypeInfo, string? contentType = null, int? statusCode = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + { + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + return new(data, statusCode, contentType) { JsonTypeInfo = jsonTypeInfo }; + } + + /// + /// Creates a that serializes the specified object to JSON. + /// + /// The type of object that will be JSON serialized to the response body. + /// The object to write as JSON. + /// A metadata provider for serializable types. + /// The content-type to set on the response. + /// The status code to set on the response. + /// The created that serializes the specified + /// as JSON format for the response. +#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters + public static JsonHttpResult Json(TValue? data, JsonSerializerContext context, string? contentType = null, int? statusCode = null) +#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters + { + ArgumentNullException.ThrowIfNull(context); + return new(data, statusCode, contentType) { - ContentType = contentType, + JsonTypeInfo = context.GetRequiredTypeInfo(typeof(TValue)) }; + } /// /// Writes the byte-array content to the response. diff --git a/src/Http/Http.Results/test/HttpResultsHelperTests.cs b/src/Http/Http.Results/test/HttpResultsHelperTests.cs index be357fce21c5..0ebbdbe017a9 100644 --- a/src/Http/Http.Results/test/HttpResultsHelperTests.cs +++ b/src/Http/Http.Results/test/HttpResultsHelperTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -24,16 +25,19 @@ public async Task WriteResultAsJsonAsync_Works_ForValueTypes(bool useJsonContext Name = "Write even more tests!", }; var responseBodyStream = new MemoryStream(); - var httpContext = CreateHttpContext(responseBodyStream, useJsonContext); + var httpContext = CreateHttpContext(responseBodyStream); + var serializerOptions = new JsonOptions().SerializerOptions; + + if (useJsonContext) + { + serializerOptions.AddContext(); + } // Act - await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value); + await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value, jsonSerializerOptions: serializerOptions); // Assert - var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), serializerOptions); Assert.Equal("Write even more tests!", body!.Name); Assert.True(body!.IsComplete); @@ -52,16 +56,19 @@ public async Task WriteResultAsJsonAsync_Works_ForReferenceTypes(bool useJsonCon Name = "Write even more tests!", }; var responseBodyStream = new MemoryStream(); - var httpContext = CreateHttpContext(responseBodyStream, useJsonContext); + var httpContext = CreateHttpContext(responseBodyStream); + var serializerOptions = new JsonOptions().SerializerOptions; + + if (useJsonContext) + { + serializerOptions.AddContext(); + } // Act - await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value); + await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value, jsonSerializerOptions: serializerOptions); // Assert - var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), serializerOptions); Assert.NotNull(body); Assert.Equal("Write even more tests!", body!.Name); @@ -82,16 +89,87 @@ public async Task WriteResultAsJsonAsync_Works_ForChildTypes(bool useJsonContext Child = "With type hierarchies!" }; var responseBodyStream = new MemoryStream(); - var httpContext = CreateHttpContext(responseBodyStream, useJsonContext); + var httpContext = CreateHttpContext(responseBodyStream); + var serializerOptions = new JsonOptions().SerializerOptions; + + if (useJsonContext) + { + serializerOptions.AddContext(); + } + + // Act + await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value, jsonSerializerOptions: serializerOptions); + + // Assert + var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), serializerOptions); + + Assert.NotNull(body); + Assert.Equal("Write even more tests!", body!.Name); + Assert.True(body!.IsComplete); + Assert.Equal("With type hierarchies!", body!.Child); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WriteResultAsJsonAsync_Works_UsingBaseType_ForChildTypes(bool useJsonContext) + { + // Arrange + var value = new TodoChild() + { + Id = 1, + IsComplete = true, + Name = "Write even more tests!", + Child = "With type hierarchies!" + }; + var responseBodyStream = new MemoryStream(); + var httpContext = CreateHttpContext(responseBodyStream); + var serializerOptions = new JsonOptions().SerializerOptions; + + if (useJsonContext) + { + serializerOptions.AddContext(); + } // Act - await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value); + await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value, jsonSerializerOptions: serializerOptions); // Assert - var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), new JsonSerializerOptions + var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), serializerOptions); + + Assert.NotNull(body); + Assert.Equal("Write even more tests!", body!.Name); + Assert.True(body!.IsComplete); + Assert.Equal("With type hierarchies!", body!.Child); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WriteResultAsJsonAsync_Works_UsingBaseType_ForChildTypes_WithJsonPolymorphism(bool useJsonContext) + { + // Arrange + var value = new TodoJsonChild() + { + Id = 1, + IsComplete = true, + Name = "Write even more tests!", + Child = "With type hierarchies!" + }; + var responseBodyStream = new MemoryStream(); + var httpContext = CreateHttpContext(responseBodyStream); + var serializerOptions = new JsonOptions().SerializerOptions; + + if (useJsonContext) { - PropertyNameCaseInsensitive = true - }); + serializerOptions.AddContext(); + } + + // Act + await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value, jsonSerializerOptions: serializerOptions); + + // Assert + var body = JsonSerializer.Deserialize(responseBodyStream.ToArray(), serializerOptions); Assert.NotNull(body); Assert.Equal("Write even more tests!", body!.Name); @@ -99,31 +177,26 @@ public async Task WriteResultAsJsonAsync_Works_ForChildTypes(bool useJsonContext Assert.Equal("With type hierarchies!", body!.Child); } - private static DefaultHttpContext CreateHttpContext(Stream stream, bool useJsonContext = false) + private static DefaultHttpContext CreateHttpContext(Stream stream) => new() { - RequestServices = CreateServices(useJsonContext), + RequestServices = CreateServices(), Response = { Body = stream, }, }; - private static IServiceProvider CreateServices(bool useJsonContext = false) + private static IServiceProvider CreateServices() { var services = new ServiceCollection(); services.AddSingleton(); - - if (useJsonContext) - { - services.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolver = TestJsonContext.Default); - } - return services.BuildServiceProvider(); } [JsonSerializable(typeof(Todo))] [JsonSerializable(typeof(TodoChild))] + [JsonSerializable(typeof(JsonTodo))] [JsonSerializable(typeof(TodoStruct))] private partial class TestJsonContext : JsonSerializerContext { } @@ -150,4 +223,14 @@ private class TodoChild : Todo { public string Child { get; set; } } + + [JsonDerivedType(typeof(TodoJsonChild))] + private class JsonTodo : Todo + { + } + + private class TodoJsonChild : JsonTodo + { + public string Child { get; set; } + } } diff --git a/src/Http/Http.Results/test/JsonResultTests.cs b/src/Http/Http.Results/test/JsonResultTests.cs index 9ee546119010..963e76c1ebd4 100644 --- a/src/Http/Http.Results/test/JsonResultTests.cs +++ b/src/Http/Http.Results/test/JsonResultTests.cs @@ -77,7 +77,7 @@ public async Task JsonResult_ExecuteAsync_JsonSerializesBody_WithOptions() var jsonOptions = new JsonSerializerOptions() { WriteIndented = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, }; var value = new Todo(10, "MyName") { Description = null }; var result = new JsonHttpResult(value, jsonSerializerOptions: jsonOptions); @@ -203,7 +203,7 @@ public async Task ExecuteAsync_SetsProblemDetailsStatus_ForValidationProblemDeta // Arrange var details = new HttpValidationProblemDetails(); - var result = new JsonHttpResult(details, StatusCodes.Status422UnprocessableEntity, jsonSerializerOptions: null); + var result = new JsonHttpResult(details, jsonSerializerOptions: null, StatusCodes.Status422UnprocessableEntity); var httpContext = new DefaultHttpContext() { RequestServices = CreateServices(), @@ -242,7 +242,7 @@ public async Task ExecuteAsync_GetsStatusCodeFromProblemDetails() public void ExecuteAsync_ThrowsArgumentNullException_WhenHttpContextIsNull() { // Arrange - var result = new JsonHttpResult(null, null); + var result = new JsonHttpResult(null, jsonSerializerOptions: null, null, null); HttpContext httpContext = null; // Act & Assert @@ -256,7 +256,7 @@ public void JsonResult_Implements_IContentTypeHttpResult_Correctly() var contentType = "application/json+custom"; // Act & Assert - var result = Assert.IsAssignableFrom(new JsonHttpResult(null, StatusCodes.Status200OK, contentType, null)); + var result = Assert.IsAssignableFrom(new JsonHttpResult(null, jsonSerializerOptions: null, StatusCodes.Status200OK, contentType)); Assert.Equal(contentType, result.ContentType); } @@ -267,7 +267,7 @@ public void JsonResult_Implements_IStatusCodeHttpResult_Correctly() var contentType = "application/json+custom"; // Act & Assert - var result = Assert.IsAssignableFrom(new JsonHttpResult(null, StatusCodes.Status202Accepted, contentType, null)); + var result = Assert.IsAssignableFrom(new JsonHttpResult(null, jsonSerializerOptions: null, StatusCodes.Status202Accepted, contentType)); Assert.Equal(StatusCodes.Status202Accepted, result.StatusCode); } @@ -278,7 +278,7 @@ public void JsonResult_Implements_IStatusCodeHttpResult_Correctly_WithNullStatus var contentType = "application/json+custom"; // Act & Assert - var result = Assert.IsAssignableFrom(new JsonHttpResult(null, statusCode: null, contentType, null)); + var result = Assert.IsAssignableFrom(new JsonHttpResult(null, jsonSerializerOptions: null, statusCode: null, contentType)); Assert.Null(result.StatusCode); } @@ -290,7 +290,7 @@ public void JsonResult_Implements_IValueHttpResult_Correctly() var contentType = "application/json+custom"; // Act & Assert - var result = Assert.IsAssignableFrom(new JsonHttpResult(value, statusCode: null, contentType, null)); + var result = Assert.IsAssignableFrom(new JsonHttpResult(value, jsonSerializerOptions: null, statusCode: null, contentType)); Assert.IsType(result.Value); Assert.Equal(value, result.Value); } @@ -303,7 +303,7 @@ public void JsonResult_Implements_IValueHttpResultOfT_Correctly() var contentType = "application/json+custom"; // Act & Assert - var result = Assert.IsAssignableFrom>(new JsonHttpResult(value, statusCode: null, contentType, null)); + var result = Assert.IsAssignableFrom>(new JsonHttpResult(value, jsonSerializerOptions: null, statusCode: null, contentType)); Assert.IsType(result.Value); Assert.Equal(value, result.Value); } diff --git a/src/Http/Http.Results/test/ResultsTests.cs b/src/Http/Http.Results/test/ResultsTests.cs index 2220232e5739..2a7d13267ccd 100644 --- a/src/Http/Http.Results/test/ResultsTests.cs +++ b/src/Http/Http.Results/test/ResultsTests.cs @@ -8,6 +8,8 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -15,7 +17,7 @@ namespace Microsoft.AspNetCore.Http.HttpResults; -public class ResultsTests +public partial class ResultsTests { [Fact] public void Accepted_WithUrlAndValue_ResultHasCorrectValues() @@ -780,6 +782,100 @@ public void Json_WithNoArgs_ResultHasCorrectValues() Assert.Null(result.StatusCode); } + [Fact] + public void Json_WithTypeInfo_ResultHasCorrectValues() + { + // Act + var result = Results.Json(null, StringJsonContext.Default.String as JsonTypeInfo) as JsonHttpResult; + + // Assert + Assert.Null(result.Value); + Assert.Null(result.JsonSerializerOptions); + Assert.Null(result.ContentType); + Assert.Null(result.StatusCode); + Assert.Equal(StringJsonContext.Default.String, result.JsonTypeInfo); + } + + [Fact] + public void Json_WithJsonContext_ResultHasCorrectValues() + { + // Act + var result = Results.Json(null, typeof(string), StringJsonContext.Default) as JsonHttpResult; + + // Assert + Assert.Null(result.Value); + Assert.Null(result.JsonSerializerOptions); + Assert.Null(result.ContentType); + Assert.Null(result.StatusCode); + Assert.IsAssignableFrom>(result.JsonTypeInfo); + } + + [Fact] + public void JsonOfT_WithTypeInfo_ResultHasCorrectValues() + { + // Act + var result = Results.Json(null, StringJsonContext.Default.String) as JsonHttpResult; + + // Assert + Assert.Null(result.Value); + Assert.Null(result.JsonSerializerOptions); + Assert.Null(result.ContentType); + Assert.Null(result.StatusCode); + Assert.Equal(StringJsonContext.Default.String, result.JsonTypeInfo); + } + + [Fact] + public void JsonOfT_WithJsonContext_ResultHasCorrectValues() + { + // Act + var result = Results.Json(null, StringJsonContext.Default) as JsonHttpResult; + + // Assert + Assert.Null(result.Value); + Assert.Null(result.JsonSerializerOptions); + Assert.Null(result.ContentType); + Assert.Null(result.StatusCode); + Assert.IsAssignableFrom>(result.JsonTypeInfo); + } + + [Fact] + public void JsonOfT_WithNullSerializerContext_ThrowsArgException() + { + Assert.Throws("context", () => Results.Json(null, context: null)); + } + + [Fact] + public void Json_WithNullSerializerContext_ThrowsArgException() + { + Assert.Throws("context", () => Results.Json(null, type: typeof(object), context: null)); + } + + [Fact] + public void Json_WithInvalidSerializerContext_ThrowsInvalidOperationException() + { + var ex = Assert.Throws(() => Results.Json(null, type: typeof(object), context: StringJsonContext.Default)); + Assert.Equal(ex.Message, $"Unable to obtain the JsonTypeInfo for type 'System.Object' from the context '{typeof(StringJsonContext).FullName}'."); + } + + [Fact] + public void JsonOfT_WithInvalidSerializerContext_ThrowsInvalidOperationException() + { + var ex = Assert.Throws(() => Results.Json(null, context: StringJsonContext.Default)); + Assert.Equal(ex.Message, $"Unable to obtain the JsonTypeInfo for type 'System.Object' from the context '{typeof(StringJsonContext).FullName}'."); + } + + [Fact] + public void Json_WithNullTypeInfo_ThrowsArgException() + { + Assert.Throws("jsonTypeInfo", () => Results.Json(null, jsonTypeInfo: null)); + } + + [Fact] + public void JsonOfT_WithNullTypeInfo_ThrowsArgException() + { + Assert.Throws("jsonTypeInfo", () => Results.Json(null, jsonTypeInfo: null)); + } + [Fact] public void LocalRedirect_WithNullStringUrl_ThrowsArgException() { @@ -1351,7 +1447,7 @@ private static string GetMemberName(Expression expression) (() => Results.File(Path.Join(Path.DirectorySeparatorChar.ToString(), "rooted", "path"), null, null, null, null, false), typeof(PhysicalFileHttpResult)), (() => Results.File("path", null, null, null, null, false), typeof(VirtualFileHttpResult)), (() => Results.Forbid(null, null), typeof(ForbidHttpResult)), - (() => Results.Json(new(), null, null, null), typeof(JsonHttpResult)), + (() => Results.Json(new(), (JsonSerializerOptions)null, null, null), typeof(JsonHttpResult)), (() => Results.NoContent(), typeof(NoContent)), (() => Results.NotFound(null), typeof(NotFound)), (() => Results.NotFound(new()), typeof(NotFound)), @@ -1377,4 +1473,8 @@ private static string GetMemberName(Expression expression) public static IEnumerable FactoryMethodsFromTuples() => FactoryMethodsTuples.Select(t => new object[] { t.Item1, t.Item2 }); private record Todo(int Id); + + [JsonSerializable(typeof(string))] + private partial class StringJsonContext : JsonSerializerContext + { } } diff --git a/src/Http/Http.Results/test/TypedResultsTests.cs b/src/Http/Http.Results/test/TypedResultsTests.cs index 067af3f2d560..93044558ef5b 100644 --- a/src/Http/Http.Results/test/TypedResultsTests.cs +++ b/src/Http/Http.Results/test/TypedResultsTests.cs @@ -6,6 +6,8 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -13,7 +15,7 @@ namespace Microsoft.AspNetCore.Http.HttpResults; -public class TypedResultsTests +public partial class TypedResultsTests { [Fact] public void Accepted_WithStringUrlAndValue_ResultHasCorrectValues() @@ -732,6 +734,65 @@ public void Json_WithNoArgs_ResultHasCorrectValues() Assert.Null(result.StatusCode); } + [Fact] + public void Json_WithTypeInfo_ResultHasCorrectValues() + { + // Arrange + var data = default(object); + + // Act + var result = TypedResults.Json(data, ObjectJsonContext.Default.Object); + + // Assert + Assert.Null(result.Value); + Assert.Null(result.JsonSerializerOptions); + Assert.Null(result.ContentType); + Assert.Null(result.StatusCode); + Assert.Equal(ObjectJsonContext.Default.Object, result.JsonTypeInfo); + } + + [Fact] + public void Json_WithJsonContext_ResultHasCorrectValues() + { + // Arrange + var data = default(object); + + // Act + var result = TypedResults.Json(data, ObjectJsonContext.Default); + + // Assert + Assert.Null(result.Value); + Assert.Null(result.JsonSerializerOptions); + Assert.Null(result.ContentType); + Assert.Null(result.StatusCode); + Assert.IsAssignableFrom>(result.JsonTypeInfo); + } + + [Fact] + public void Json_WithNullSerializerContext_ThrowsArgException() + { + // Arrange + var data = default(object); + + Assert.Throws("context", () => TypedResults.Json(data, context: null)); + } + + [Fact] + public void Json_WithInvalidSerializerContext_ThrowsInvalidOperationException() + { + var ex = Assert.Throws(() => TypedResults.Json(string.Empty, context: ObjectJsonContext.Default)); + Assert.Equal(ex.Message, $"Unable to obtain the JsonTypeInfo for type 'System.String' from the context '{typeof(ObjectJsonContext).FullName}'."); + } + + [Fact] + public void Json_WithNullTypeInfo_ThrowsArgException() + { + // Arrange + var data = default(object); + + Assert.Throws("jsonTypeInfo", () => TypedResults.Json(data, jsonTypeInfo: null)); + } + [Fact] public void LocalRedirect_WithNullStringUrl_ThrowsArgException() { @@ -1213,4 +1274,8 @@ public void UnprocessableEntity_ResultHasCorrectValues() // Assert Assert.Equal(StatusCodes.Status422UnprocessableEntity, result.StatusCode); } + + [JsonSerializable(typeof(object))] + private partial class ObjectJsonContext : JsonSerializerContext + { } } diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs index fb0833174549..b0f68a771377 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs @@ -72,7 +72,7 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon { var declaredTypeJsonInfo = SerializerOptions.GetTypeInfo(context.ObjectType); - if (declaredTypeJsonInfo.IsPolymorphicSafe() || context.Object is null || runtimeType == declaredTypeJsonInfo.Type) + if (declaredTypeJsonInfo.IsValid(runtimeType)) { jsonTypeInfo = declaredTypeJsonInfo; } diff --git a/src/Shared/Json/JsonSerializerExtensions.cs b/src/Shared/Json/JsonSerializerExtensions.cs index bf53e0f21993..0c0638dc4675 100644 --- a/src/Shared/Json/JsonSerializerExtensions.cs +++ b/src/Shared/Json/JsonSerializerExtensions.cs @@ -1,19 +1,27 @@ // 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.Json; +using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; namespace Microsoft.AspNetCore.Http; internal static class JsonSerializerExtensions { - public static bool IsPolymorphicSafe(this JsonTypeInfo jsonTypeInfo) + 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) + => runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.HasKnownPolymorphism(); + public static JsonTypeInfo GetReadOnlyTypeInfo(this JsonSerializerOptions options, Type type) { options.MakeReadOnly(); return options.GetTypeInfo(type); } + + public static JsonTypeInfo GetRequiredTypeInfo(this JsonSerializerContext context, Type type) + => context.GetTypeInfo(type) ?? throw new InvalidOperationException($"Unable to obtain the JsonTypeInfo for type '{type.FullName}' from the context '{context.GetType().FullName}'."); } diff --git a/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs b/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs index e29b29dadee6..12217930d751 100644 --- a/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs +++ b/src/Shared/RouteHandlers/ExecuteHandlerHelper.cs @@ -37,7 +37,7 @@ public static Task WriteJsonResponseAsync(HttpResponse response, T? value, Js { var runtimeType = value?.GetType(); - if (runtimeType is null || jsonTypeInfo.Type == runtimeType || jsonTypeInfo.IsPolymorphicSafe()) + if (jsonTypeInfo.IsValid(runtimeType)) { // In this case the polymorphism is not // relevant for us and will be handled by STJ, if needed.