Browse Source

Making `JsonOptions` AOT/Trimmer-safe with EnsureJsonTrimmability switch (#45886)

* Adding EnsureJsonTrimmability switch

* Set TypeResolver to null

* Removing RUC/RDC attributes

* Removing ProblemDetails.Extension RUC/RDC

* Adding Test remote execution support

* Adding jsonoptions tests

* Update ProblemDetails.cs

* Update HttpValidationProblemDetailsJsonConverter.cs
Bruno Oliveira 3 years ago
parent
commit
40dd2305cf

+ 1 - 0
eng/Dependencies.props

@@ -141,6 +141,7 @@ and are generated based on the last package release.
     <LatestPackageReference Include="Microsoft.Internal.Runtime.AspNetCore.Transport" />
     <LatestPackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
     <LatestPackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" />
+    <LatestPackageReference Include="Microsoft.DotNet.RemoteExecutor" />
     <LatestPackageReference Include="Microsoft.EntityFrameworkCore.Design" />
     <LatestPackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
     <LatestPackageReference Include="Microsoft.EntityFrameworkCore.Relational" />

+ 4 - 0
eng/Version.Details.xml

@@ -319,5 +319,9 @@
       <Uri>https://github.com/dotnet/arcade</Uri>
       <Sha>1b04d6de502c4108ada6ea8e5ccefdc2ddc3ee7b</Sha>
     </Dependency>
+    <Dependency Name="Microsoft.DotNet.RemoteExecutor" Version="8.0.0-beta.23063.7">
+      <Uri>https://github.com/dotnet/arcade</Uri>
+      <Sha>000000</Sha>
+    </Dependency>
   </ToolsetDependencies>
 </Dependencies>

+ 1 - 0
eng/Versions.props

@@ -137,6 +137,7 @@
     <!-- Packages from dotnet/arcade -->
     <MicrosoftDotNetBuildTasksInstallersVersion>8.0.0-beta.23063.7</MicrosoftDotNetBuildTasksInstallersVersion>
     <MicrosoftDotNetBuildTasksTemplatingVersion>8.0.0-beta.23063.7</MicrosoftDotNetBuildTasksTemplatingVersion>
+    <MicrosoftDotNetRemoteExecutorVersion>8.0.0-beta.23063.7</MicrosoftDotNetRemoteExecutorVersion>
     <!-- Packages from dotnet/source-build-externals -->
     <MicrosoftSourceBuildIntermediatesourcebuildexternalsVersion>8.0.0-alpha.1.23062.2</MicrosoftSourceBuildIntermediatesourcebuildexternalsVersion>
     <!-- Packages from dotnet/xdt -->

+ 1 - 9
src/Http/Http.Abstractions/src/ProblemDetails/ProblemDetails.cs

@@ -1,7 +1,6 @@
 // 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.Serialization;
 using Microsoft.AspNetCore.Http;
 
@@ -13,8 +12,6 @@ namespace Microsoft.AspNetCore.Mvc;
 [JsonConverter(typeof(ProblemDetailsJsonConverter))]
 public class ProblemDetails
 {
-    private readonly IDictionary<string, object?> _extensions = new Dictionary<string, object?>(StringComparer.Ordinal);
-
     /// <summary>
     /// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when
     /// dereferenced, it provide human-readable documentation for the problem type
@@ -62,10 +59,5 @@ public class ProblemDetails
     /// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters.
     /// </remarks>
     [JsonExtensionData]
-    public IDictionary<string, object?> Extensions
-    {
-        [RequiresUnreferencedCode("JSON serialization and deserialization of ProblemDetails.Extensions might require types that cannot be statically analyzed.")]
-        [RequiresDynamicCode("JSON serialization and deserialization of ProblemDetails.Extensions might require types that cannot be statically analyzed.")]
-        get => _extensions;
-    }
+    public IDictionary<string, object?> Extensions { get; } = new Dictionary<string, object?>(StringComparer.Ordinal);
 }

+ 66 - 0
src/Http/Http.Abstractions/test/ProblemDetailsJsonConverterTest.cs

@@ -3,6 +3,7 @@
 
 using System.Text;
 using System.Text.Json;
+using System.Text.Json.Nodes;
 using Microsoft.AspNetCore.Http.Json;
 using Microsoft.AspNetCore.Mvc;
 
@@ -92,6 +93,39 @@ public class ProblemDetailsJsonConverterTest
             });
     }
 
+    [Fact]
+    public void Read_WithUnknownTypeHandling_Works()
+    {
+        // Arrange
+        var type = "https://tools.ietf.org/html/rfc9110#section-15.5.5";
+        var title = "Not found";
+        var status = 404;
+        var detail = "Product not found";
+        var instance = "http://example.com/products/14";
+        var traceId = "|37dd3dd5-4a9619f953c40a16.";
+        var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"detail\":\"{detail}\", \"instance\":\"{instance}\",\"traceId\":\"{traceId}\"}}";
+        var serializerOptions = new JsonSerializerOptions(JsonSerializerOptions) { UnknownTypeHandling = System.Text.Json.Serialization.JsonUnknownTypeHandling.JsonNode };
+
+        // Act
+        var problemDetails = JsonSerializer.Deserialize<ProblemDetails>(json, serializerOptions);
+
+        // Assert
+        Assert.NotNull(problemDetails);
+        Assert.Equal(type, problemDetails!.Type);
+        Assert.Equal(title, problemDetails.Title);
+        Assert.Equal(status, problemDetails.Status);
+        Assert.Equal(instance, problemDetails.Instance);
+        Assert.Equal(detail, problemDetails.Detail);
+        Assert.Collection(
+            problemDetails.Extensions,
+            kvp =>
+            {
+                Assert.Equal("traceId", kvp.Key);
+                Assert.IsAssignableFrom<JsonNode>(kvp.Value!);
+                Assert.Equal(traceId, kvp.Value?.ToString());
+            });
+    }
+
     [Fact]
     public void Read_WithSomeMissingValues_Works()
     {
@@ -178,4 +212,36 @@ public class ProblemDetailsJsonConverterTest
         var actual = Encoding.UTF8.GetString(stream.ToArray());
         Assert.Equal(expected, actual);
     }
+
+    [Fact]
+    public void Write_WithNullExtensionValue_Works()
+    {
+        // Arrange
+        var value = new ProblemDetails
+        {
+            Title = "Not found",
+            Type = "https://tools.ietf.org/html/rfc9110#section-15.5.5",
+            Status = 404,
+            Detail = "Product not found",
+            Instance = "http://example.com/products/14",
+            Extensions =
+                {
+                    { "traceId", null },
+                    { "some-data", new[] { "value1", "value2" } }
+                }
+        };
+        var expected = $"{{\"type\":\"{JsonEncodedText.Encode(value.Type)}\",\"title\":\"{value.Title}\",\"status\":{value.Status},\"detail\":\"{value.Detail}\",\"instance\":\"{JsonEncodedText.Encode(value.Instance)}\",\"traceId\":null,\"some-data\":[\"value1\",\"value2\"]}}";
+        var converter = new ProblemDetailsJsonConverter();
+        var stream = new MemoryStream();
+
+        // Act
+        using (var writer = new Utf8JsonWriter(stream))
+        {
+            converter.Write(writer, value, JsonSerializerOptions);
+        }
+
+        // Assert
+        var actual = Encoding.UTF8.GetString(stream.ToArray());
+        Assert.Equal(expected, actual);
+    }
 }

+ 8 - 6
src/Http/Http.Extensions/src/HttpRequestJsonExtensions.cs

@@ -32,13 +32,14 @@ public static class HttpRequestJsonExtensions
     /// <param name="request">The request to read from.</param>
     /// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
     /// <returns>The task object representing the asynchronous operation.</returns>
-    [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
-    [RequiresDynamicCode(RequiresDynamicCodeMessage)]
     public static ValueTask<TValue?> ReadFromJsonAsync<TValue>(
         this HttpRequest request,
         CancellationToken cancellationToken = default)
     {
-        return request.ReadFromJsonAsync<TValue>(options: null, cancellationToken);
+        ArgumentNullException.ThrowIfNull(request);
+
+        var options = ResolveSerializerOptions(request.HttpContext);
+        return request.ReadFromJsonAsync(jsonTypeInfo: (JsonTypeInfo<TValue>)options.GetTypeInfo(typeof(TValue)), cancellationToken);
     }
 
     /// <summary>
@@ -166,14 +167,15 @@ public static class HttpRequestJsonExtensions
     /// <param name="type">The type of object to read.</param>
     /// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
     /// <returns>The task object representing the asynchronous operation.</returns>
-    [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
-    [RequiresDynamicCode(RequiresDynamicCodeMessage)]
     public static ValueTask<object?> ReadFromJsonAsync(
         this HttpRequest request,
         Type type,
         CancellationToken cancellationToken = default)
     {
-        return request.ReadFromJsonAsync(type, options: null, cancellationToken);
+        ArgumentNullException.ThrowIfNull(request);
+
+        var options = ResolveSerializerOptions(request.HttpContext);
+        return request.ReadFromJsonAsync(jsonTypeInfo: options.GetTypeInfo(type), cancellationToken);
     }
 
     /// <summary>

+ 8 - 6
src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs

@@ -30,14 +30,15 @@ public static partial class HttpResponseJsonExtensions
     /// <param name="value">The value to write as JSON.</param>
     /// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
     /// <returns>The task object representing the asynchronous operation.</returns>
-    [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
-    [RequiresDynamicCode(RequiresDynamicCodeMessage)]
     public static Task WriteAsJsonAsync<TValue>(
         this HttpResponse response,
         TValue value,
         CancellationToken cancellationToken = default)
     {
-        return response.WriteAsJsonAsync(value, options: null, contentType: null, cancellationToken);
+        ArgumentNullException.ThrowIfNull(response);
+
+        var options = ResolveSerializerOptions(response.HttpContext);
+        return response.WriteAsJsonAsync(value, jsonTypeInfo: (JsonTypeInfo<TValue>)options.GetTypeInfo(typeof(TValue)), contentType: null, cancellationToken);
     }
 
     /// <summary>
@@ -203,15 +204,16 @@ public static partial class HttpResponseJsonExtensions
     /// <param name="type">The type of object to write.</param>
     /// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
     /// <returns>The task object representing the asynchronous operation.</returns>
-    [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
-    [RequiresDynamicCode(RequiresDynamicCodeMessage)]
     public static Task WriteAsJsonAsync(
         this HttpResponse response,
         object? value,
         Type type,
         CancellationToken cancellationToken = default)
     {
-        return response.WriteAsJsonAsync(value, type, options: null, contentType: null, cancellationToken);
+        ArgumentNullException.ThrowIfNull(response);
+
+        var options = ResolveSerializerOptions(response.HttpContext);
+        return response.WriteAsJsonAsync(value, jsonTypeInfo: options.GetTypeInfo(type), contentType: null, cancellationToken);
     }
 
     /// <summary>

+ 2 - 1
src/Http/Http.Extensions/src/JsonOptions.cs

@@ -4,6 +4,7 @@
 using System.Text.Encodings.Web;
 using System.Text.Json;
 using System.Text.Json.Serialization.Metadata;
+using Microsoft.AspNetCore.Internal;
 
 #nullable enable
 
@@ -26,7 +27,7 @@ public class JsonOptions
         // 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<TContext>()
-        TypeInfoResolver = CreateDefaultTypeResolver()
+        TypeInfoResolver = TrimmingAppContextSwitches.EnsureJsonTrimmability ? null : CreateDefaultTypeResolver()
     };
 
     // Use a copy so the defaults are not modified.

+ 9 - 7
src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj

@@ -12,17 +12,18 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" LinkBase="Shared"/>
-    <Compile Include="$(SharedSourceRoot)ParameterBindingMethodCache.cs" LinkBase="Shared"/>
-    <Compile Include="$(SharedSourceRoot)EndpointMetadataPopulator.cs" LinkBase="Shared"/>
-    <Compile Include="$(SharedSourceRoot)PropertyAsParameterInfo.cs" LinkBase="Shared"/>
+    <Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)ParameterBindingMethodCache.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)EndpointMetadataPopulator.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)PropertyAsParameterInfo.cs" LinkBase="Shared" />
     <Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" LinkBase="Shared" />
     <Compile Include="$(SharedSourceRoot)ApiExplorerTypes\*.cs" LinkBase="Shared" />
     <Compile Include="$(SharedSourceRoot)RoutingMetadata\AcceptsMetadata.cs" LinkBase="Shared" />
-    <Compile Include="$(SharedSourceRoot)TypeNameHelper\TypeNameHelper.cs" LinkBase="Shared"/>
+    <Compile Include="$(SharedSourceRoot)TypeNameHelper\TypeNameHelper.cs" LinkBase="Shared" />
     <Compile Include="$(SharedSourceRoot)ProblemDetails\ProblemDetailsDefaults.cs" LinkBase="Shared" />
-    <Compile Include="$(SharedSourceRoot)ValueStringBuilder\**\*.cs" LinkBase="Shared"/>
-    <Compile Include="$(SharedSourceRoot)Json\JsonSerializerExtensions.cs" LinkBase="Shared"/>
+    <Compile Include="$(SharedSourceRoot)ValueStringBuilder\**\*.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)Json\JsonSerializerExtensions.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)TrimmingAppContextSwitches.cs" LinkBase="Shared" />
     <Compile Include="$(SharedSourceRoot)RouteHandlers\ExecuteHandlerHelper.cs" LinkBase="Shared"/>
   </ItemGroup>
 
@@ -36,4 +37,5 @@
   <ItemGroup>
     <InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Extensions.Tests" />
   </ItemGroup>
+
 </Project>

+ 2 - 0
src/Http/Http.Extensions/src/ProblemDetailsJsonContext.cs

@@ -9,6 +9,8 @@ namespace Microsoft.AspNetCore.Http;
 
 [JsonSerializable(typeof(ProblemDetails))]
 [JsonSerializable(typeof(HttpValidationProblemDetails))]
+// ExtensionData
+[JsonSerializable(typeof(IDictionary<string, object?>))]
 // Additional values are specified on JsonSerializerContext to support some values for extensions.
 // For example, the DeveloperExceptionMiddleware serializes its complex type to JsonElement, which problem details then needs to serialize.
 [JsonSerializable(typeof(JsonElement))]

+ 8 - 0
src/Http/Http.Extensions/src/Properties/ILLink.Substitutions.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<linker>
+  <assembly fullname="Microsoft.AspNetCore.Http.Extensions">
+    <type fullname="Microsoft.AspNetCore.Internal.TrimmingAppContextSwitches">
+      <method signature="System.Boolean get_EnsureJsonTrimmability()" body="stub" value="true" feature="Microsoft.AspNetCore.EnsureJsonTrimmability" featurevalue="true" />
+    </type>
+  </assembly>
+</linker>

+ 47 - 0
src/Http/Http.Extensions/test/JsonOptionsTests.cs

@@ -0,0 +1,47 @@
+// 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.Metadata;
+using Microsoft.AspNetCore.Http.Json;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.DotNet.RemoteExecutor;
+
+namespace Microsoft.AspNetCore.Http.Extensions;
+
+public class JsonOptionsTests
+{
+    [ConditionalFact]
+    [RemoteExecutionSupported]
+    public void DefaultSerializerOptions_SetsTypeInfoResolverNull_WhenEnsureJsonTrimmabilityTrue()
+    {
+        var options = new RemoteInvokeOptions();
+        options.RuntimeConfigurationOptions.Add("Microsoft.AspNetCore.EnsureJsonTrimmability", true.ToString());
+
+        using var remoteHandle = RemoteExecutor.Invoke(static () =>
+        {
+            // Arrange
+            var options = JsonOptions.DefaultSerializerOptions;
+
+            // Assert
+            Assert.Null(options.TypeInfoResolver);
+        }, options);
+    }
+
+    [ConditionalFact]
+    [RemoteExecutionSupported]
+    public void DefaultSerializerOptions_SetsTypeInfoResolverToDefault_WhenEnsureJsonTrimmabilityFalse()
+    {
+        var options = new RemoteInvokeOptions();
+        options.RuntimeConfigurationOptions.Add("Microsoft.AspNetCore.EnsureJsonTrimmability", false.ToString());
+
+        using var remoteHandle = RemoteExecutor.Invoke(static () =>
+        {
+            // Arrange
+            var options = JsonOptions.DefaultSerializerOptions;
+
+            // Assert
+            Assert.NotNull(options.TypeInfoResolver);
+            Assert.IsType<DefaultJsonTypeInfoResolver>(options.TypeInfoResolver);
+        }, options);
+    }
+}

+ 0 - 2
src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs

@@ -202,8 +202,6 @@ internal class DeveloperExceptionPageMiddlewareImpl
         }
     }
 
-    [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Values set on ProblemDetails.Extensions are supported by the default writer.")]
-    [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Values set on ProblemDetails.Extensions are supported by the default writer.")]
     private ProblemDetails CreateProblemDetails(ErrorContext errorContext, HttpContext httpContext)
     {
         var problemDetails = new ProblemDetails

+ 2 - 1
src/Mvc/Mvc.Core/src/JsonOptions.cs

@@ -3,6 +3,7 @@
 
 using System.Text.Json;
 using System.Text.Json.Serialization.Metadata;
+using Microsoft.AspNetCore.Internal;
 using Microsoft.AspNetCore.Mvc.Formatters;
 using Microsoft.AspNetCore.Mvc.ModelBinding;
 
@@ -42,7 +43,7 @@ public class JsonOptions
         // 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<TContext>()
-        TypeInfoResolver = CreateDefaultTypeResolver()
+        TypeInfoResolver = TrimmingAppContextSwitches.EnsureJsonTrimmability ? null : CreateDefaultTypeResolver()
     };
 
     private static IJsonTypeInfoResolver CreateDefaultTypeResolver()

+ 5 - 0
src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj

@@ -33,6 +33,7 @@ Microsoft.AspNetCore.Mvc.RouteAttribute</Description>
     <Compile Include="$(SharedSourceRoot)MediaType\ReadOnlyMediaTypeHeaderValue.cs" LinkBase="Shared" />
     <Compile Include="$(SharedSourceRoot)MediaType\HttpTokenParsingRule.cs" LinkBase="Shared" />
     <Compile Include="$(SharedSourceRoot)RoutingMetadata\AcceptsMetadata.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)TrimmingAppContextSwitches.cs" LinkBase="Shared" />
     <Compile Include="$(SharedSourceRoot)Json\JsonSerializerExtensions.cs" LinkBase="Shared"/>
   </ItemGroup>
 
@@ -61,6 +62,10 @@ Microsoft.AspNetCore.Mvc.RouteAttribute</Description>
     <Compile Include="$(SharedSourceRoot)ParameterDefaultValue\*.cs" />
   </ItemGroup>
 
+  <ItemGroup>
+    <EmbeddedResource Include="Properties\ILLink.Substitutions.xml" LogicalName="ILLink.Substitutions.xml" />
+  </ItemGroup>
+
   <ItemGroup>
     <InternalsVisibleTo Include="Microsoft.AspNetCore.Mvc" />
     <InternalsVisibleTo Include="Microsoft.AspNetCore.Mvc.ApiExplorer" />

+ 8 - 0
src/Mvc/Mvc.Core/src/Properties/ILLink.Substitutions.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<linker>
+  <assembly fullname="Microsoft.AspNetCore.Mvc.Core">
+    <type fullname="Microsoft.AspNetCore.Internal.TrimmingAppContextSwitches">
+      <method signature="System.Boolean get_EnsureJsonTrimmability()" body="stub" value="true" feature="Microsoft.AspNetCore.EnsureJsonTrimmability" featurevalue="true" />
+    </type>
+  </assembly>
+</linker>

+ 46 - 0
src/Mvc/Mvc.Core/test/JsonOptionsTest.cs

@@ -0,0 +1,46 @@
+// 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.Metadata;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.DotNet.RemoteExecutor;
+
+namespace Microsoft.AspNetCore.Mvc;
+
+public class JsonOptionsTest
+{
+    [ConditionalFact]
+    [RemoteExecutionSupported]
+    public void DefaultSerializerOptions_SetsTypeInfoResolverNull_WhenEnsureJsonTrimmabilityTrue()
+    {
+        var options = new RemoteInvokeOptions();
+        options.RuntimeConfigurationOptions.Add("Microsoft.AspNetCore.EnsureJsonTrimmability", true.ToString());
+
+        using var remoteHandle = RemoteExecutor.Invoke(static () =>
+        {
+            // Arrange
+            var options = new JsonOptions().JsonSerializerOptions;
+
+            // Assert
+            Assert.Null(options.TypeInfoResolver);
+        }, options);
+    }
+
+    [ConditionalFact]
+    [RemoteExecutionSupported]
+    public void DefaultSerializerOptions_SetsTypeInfoResolverToDefault_WhenEnsureJsonTrimmabilityFalse()
+    {
+        var options = new RemoteInvokeOptions();
+        options.RuntimeConfigurationOptions.Add("Microsoft.AspNetCore.EnsureJsonTrimmability", false.ToString());
+
+        using var remoteHandle = RemoteExecutor.Invoke(static () =>
+        {
+            // Arrange
+            var options = new JsonOptions().JsonSerializerOptions;
+
+            // Assert
+            Assert.NotNull(options.TypeInfoResolver);
+            Assert.IsType<DefaultJsonTypeInfoResolver>(options.TypeInfoResolver);
+        }, options);
+    }
+}

+ 2 - 1
src/Shared/ProblemDetails/HttpValidationProblemDetailsJsonConverter.cs

@@ -23,6 +23,7 @@ internal sealed class HttpValidationProblemDetailsJsonConverter : JsonConverter<
             throw new JsonException("Unexpected end when reading JSON.");
         }
 
+        var objectTypeInfo = options.GetTypeInfo(typeof(object));
         while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
         {
             if (reader.ValueTextEquals(Errors.EncodedUtf8Bytes))
@@ -31,7 +32,7 @@ internal sealed class HttpValidationProblemDetailsJsonConverter : JsonConverter<
             }
             else
             {
-                ProblemDetailsJsonConverter.ReadValue(ref reader, problemDetails, options);
+                ProblemDetailsJsonConverter.ReadValue(ref reader, problemDetails, objectTypeInfo);
             }
         }
 

+ 14 - 19
src/Shared/ProblemDetails/ProblemDetailsJsonConverter.cs

@@ -4,6 +4,7 @@
 using System.Diagnostics.CodeAnalysis;
 using System.Text.Json;
 using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
 using Microsoft.AspNetCore.Mvc;
 
 namespace Microsoft.AspNetCore.Http;
@@ -25,9 +26,10 @@ internal sealed class ProblemDetailsJsonConverter : JsonConverter<ProblemDetails
             throw new JsonException("Unexpected end when reading JSON.");
         }
 
+        var objectTypeInfo = options.GetTypeInfo(typeof(object));
         while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
         {
-            ReadValue(ref reader, problemDetails, options);
+            ReadValue(ref reader, problemDetails, objectTypeInfo);
         }
 
         if (reader.TokenType != JsonTokenType.EndObject)
@@ -45,7 +47,7 @@ internal sealed class ProblemDetailsJsonConverter : JsonConverter<ProblemDetails
         writer.WriteEndObject();
     }
 
-    internal static void ReadValue(ref Utf8JsonReader reader, ProblemDetails value, JsonSerializerOptions options)
+    internal static void ReadValue(ref Utf8JsonReader reader, ProblemDetails value, JsonTypeInfo extensionDataTypeInfo)
     {
         if (TryReadStringProperty(ref reader, Type, out var propertyValue))
         {
@@ -79,14 +81,7 @@ internal sealed class ProblemDetailsJsonConverter : JsonConverter<ProblemDetails
         {
             var key = reader.GetString()!;
             reader.Read();
-            ReadExtension(value, key, ref reader, options);
-        }
-
-        [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "ProblemDetails.Extensions is annotated to expose this warning to callers.")]
-        [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "ProblemDetails.Extensions is annotated to expose this warning to callers.")]
-        static void ReadExtension(ProblemDetails problemDetails, string key, ref Utf8JsonReader reader, JsonSerializerOptions options)
-        {
-            problemDetails.Extensions[key] = JsonSerializer.Deserialize(ref reader, typeof(object), options);
+            value.Extensions[key] = JsonSerializer.Deserialize(ref reader, extensionDataTypeInfo);
         }
     }
 
@@ -130,17 +125,17 @@ internal sealed class ProblemDetailsJsonConverter : JsonConverter<ProblemDetails
             writer.WriteString(Instance, value.Instance);
         }
 
-        WriteExtensions(value, writer, options);
-
-        [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "ProblemDetails.Extensions is annotated to expose this warning to callers.")]
-        [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "ProblemDetails.Extensions is annotated to expose this warning to callers.")]
-        static void WriteExtensions(ProblemDetails problemDetails, Utf8JsonWriter writer, JsonSerializerOptions options)
+        foreach (var kvp in value.Extensions)
         {
-            foreach (var kvp in problemDetails.Extensions)
+            writer.WritePropertyName(kvp.Key);
+
+            if (kvp.Value is null)
+            {
+                writer.WriteNullValue();
+            }
+            else
             {
-                writer.WritePropertyName(kvp.Key);
-                // When AOT is enabled, Serialize will only work with values specified on the JsonContext.
-                JsonSerializer.Serialize(writer, kvp.Value, kvp.Value?.GetType() ?? typeof(object), options);
+                JsonSerializer.Serialize(writer, kvp.Value, options.GetTypeInfo(kvp.Value.GetType()));
             }
         }
     }

+ 11 - 0
src/Shared/TrimmingAppContextSwitches.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.Internal;
+
+internal sealed class TrimmingAppContextSwitches
+{
+    private const string EnsureJsonTrimmabilityKey = "Microsoft.AspNetCore.EnsureJsonTrimmability";
+
+    internal static bool EnsureJsonTrimmability { get; } = AppContext.TryGetSwitch(EnsureJsonTrimmabilityKey, out var enabled) && enabled;
+}

+ 1 - 0
src/Testing/src/Microsoft.AspNetCore.Testing.csproj

@@ -40,6 +40,7 @@
     -->
     <Reference Include="xunit.assert" />
     <Reference Include="xunit.extensibility.execution" />
+    <Reference Include="Microsoft.DotNet.RemoteExecutor" />
   </ItemGroup>
 
   <ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0' OR

+ 22 - 0
src/Testing/src/xunit/RemoteExecutionSupportedAttribute.cs

@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Runtime.InteropServices;
+#if !NETSTANDARD2_0
+using Microsoft.DotNet.RemoteExecutor;
+#endif
+
+namespace Microsoft.AspNetCore.Testing;
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)]
+public class RemoteExecutionSupportedAttribute : Attribute, ITestCondition
+{
+#if NETSTANDARD2_0
+    public bool IsMet => false;
+#else
+    public bool IsMet => RemoteExecutor.IsSupported;
+#endif
+
+    public string SkipReason { get; set; }
+}