Skip to content

Commit 0020e0b

Browse files
authored
Support STJ Polymorphism (#45405)
* Initial GetTypeInfo usage * Merging 45359 * Using TypeInfoAPI * Updating comments * Remove using * Updating MVC support * Adding fastpath * Trying to fix mvc * Updating mvc support * Simplify MVC * Moving more to fastpath * Adding JsonOptions setup * Moving DefaultJsonTypeInfoResolver instance creation * Fix bad merge * Adding RDF unit tests * Adding more RDF tests * Adding MVC unit tests * Updating IL2026 warnings * Scoping trimming warning * Updates * Changing to always JsonTypeInfo * Adding JsonSerializerExtensions * Fixing warnings * Apply suggestions from code review * Updating suppression * Adding IL3050
1 parent c096dbb commit 0020e0b

13 files changed

+471
-55
lines changed

src/Http/Http.Extensions/src/JsonOptions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Text.Encodings.Web;
55
using System.Text.Json;
6+
using System.Text.Json.Serialization.Metadata;
67

78
#nullable enable
89

@@ -21,11 +22,23 @@ public class JsonOptions
2122
// Because these options are for producing content that is written directly to the request
2223
// (and not embedded in an HTML page for example), we can use UnsafeRelaxedJsonEscaping.
2324
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
25+
26+
// The JsonSerializerOptions.GetTypeInfo method is called directly and needs a defined resolver
27+
// setting the default resolver (reflection-based) but the user can overwrite it directly or calling
28+
// .AddContext<TContext>()
29+
TypeInfoResolver = CreateDefaultTypeResolver()
2430
};
2531

2632
// Use a copy so the defaults are not modified.
2733
/// <summary>
2834
/// Gets the <see cref="JsonSerializerOptions"/>.
2935
/// </summary>
3036
public JsonSerializerOptions SerializerOptions { get; } = new JsonSerializerOptions(DefaultSerializerOptions);
37+
38+
#pragma warning disable IL2026 // Suppressed in Microsoft.AspNetCore.Http.Extensions.WarningSuppressions.xml
39+
#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
40+
private static IJsonTypeInfoResolver CreateDefaultTypeResolver()
41+
=> new DefaultJsonTypeInfoResolver();
42+
#pragma warning restore IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
43+
#pragma warning restore IL2026 // Suppressed in Microsoft.AspNetCore.Http.Extensions.WarningSuppressions.xml
3144
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<linker>
3+
<assembly fullname="Microsoft.AspNetCore.Http.Extensions, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
4+
<attribute fullname="System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessageAttribute">
5+
<argument>ILLink</argument>
6+
<argument>IL2026</argument>
7+
<property name="Scope">member</property>
8+
<property name="Target">M:Microsoft.AspNetCore.Http.Json.JsonOptions.CreateDefaultTypeResolver</property>
9+
<property name="Justification">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.</property>
10+
</attribute>
11+
</assembly>
12+
</linker>

src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
<Compile Include="$(SharedSourceRoot)RoutingMetadata\AcceptsMetadata.cs" LinkBase="Shared" />
2222
<Compile Include="$(SharedSourceRoot)TypeNameHelper\TypeNameHelper.cs" LinkBase="Shared"/>
2323
<Compile Include="$(SharedSourceRoot)ProblemDetails\ProblemDetailsDefaults.cs" LinkBase="Shared" />
24-
<Compile Include="$(SharedSourceRoot)ValueStringBuilder\**\*.cs" LingBase="Shared"/>
24+
<Compile Include="$(SharedSourceRoot)ValueStringBuilder\**\*.cs" LinkBase="Shared"/>
25+
<Compile Include="$(SharedSourceRoot)Json\JsonSerializerExtensions.cs" LinkBase="Shared"/>
2526
</ItemGroup>
2627

2728
<ItemGroup>

src/Http/Http.Extensions/src/RequestDelegateFactory.cs

Lines changed: 138 additions & 44 deletions
Large diffs are not rendered by default.

src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
using System.Security.Cryptography.X509Certificates;
1818
using System.Text;
1919
using System.Text.Json;
20+
using System.Text.Json.Nodes;
2021
using System.Text.Json.Serialization;
22+
using System.Text.Json.Serialization.Metadata;
2123
using Microsoft.AspNetCore.Builder;
2224
using Microsoft.AspNetCore.Http;
2325
using Microsoft.AspNetCore.Http.Features;
@@ -31,6 +33,7 @@
3133
using Microsoft.Extensions.Options;
3234
using Microsoft.Extensions.Primitives;
3335
using Moq;
36+
using Xunit.Abstractions;
3437

3538
namespace Microsoft.AspNetCore.Routing.Internal;
3639

@@ -3070,6 +3073,122 @@ public async Task RequestDelegateWritesMembersFromChildTypesToJsonResponseBody(D
30703073
Assert.Equal("With type hierarchies!", deserializedResponseBody!.Child);
30713074
}
30723075

3076+
public static IEnumerable<object[]> PolymorphicResult
3077+
{
3078+
get
3079+
{
3080+
JsonTodoChild originalTodo = new()
3081+
{
3082+
Name = "Write even more tests!",
3083+
Child = "With type hierarchies!",
3084+
};
3085+
3086+
JsonTodo TestAction() => originalTodo;
3087+
3088+
Task<JsonTodo> TaskTestAction() => Task.FromResult<JsonTodo>(originalTodo);
3089+
async Task<JsonTodo> TaskTestActionAwaited()
3090+
{
3091+
await Task.Yield();
3092+
return originalTodo;
3093+
}
3094+
3095+
ValueTask<JsonTodo> ValueTaskTestAction() => ValueTask.FromResult<JsonTodo>(originalTodo);
3096+
async ValueTask<JsonTodo> ValueTaskTestActionAwaited()
3097+
{
3098+
await Task.Yield();
3099+
return originalTodo;
3100+
}
3101+
3102+
return new List<object[]>
3103+
{
3104+
new object[] { (Func<JsonTodo>)TestAction },
3105+
new object[] { (Func<Task<JsonTodo>>)TaskTestAction},
3106+
new object[] { (Func<Task<JsonTodo>>)TaskTestActionAwaited},
3107+
new object[] { (Func<ValueTask<JsonTodo>>)ValueTaskTestAction},
3108+
new object[] { (Func<ValueTask<JsonTodo>>)ValueTaskTestActionAwaited},
3109+
};
3110+
}
3111+
}
3112+
3113+
[Theory]
3114+
[MemberData(nameof(PolymorphicResult))]
3115+
public async Task RequestDelegateWritesMembersFromChildTypesToJsonResponseBody_WithJsonPolymorphicOptions(Delegate @delegate)
3116+
{
3117+
var httpContext = CreateHttpContext();
3118+
httpContext.RequestServices = new ServiceCollection()
3119+
.AddSingleton(LoggerFactory)
3120+
.AddSingleton(Options.Create(new JsonOptions()))
3121+
.BuildServiceProvider();
3122+
var responseBodyStream = new MemoryStream();
3123+
httpContext.Response.Body = responseBodyStream;
3124+
3125+
var factoryResult = RequestDelegateFactory.Create(@delegate);
3126+
var requestDelegate = factoryResult.RequestDelegate;
3127+
3128+
await requestDelegate(httpContext);
3129+
3130+
var deserializedResponseBody = JsonSerializer.Deserialize<JsonTodoChild>(responseBodyStream.ToArray(), new JsonSerializerOptions
3131+
{
3132+
PropertyNameCaseInsensitive = true
3133+
});
3134+
3135+
Assert.NotNull(deserializedResponseBody);
3136+
Assert.Equal("Write even more tests!", deserializedResponseBody!.Name);
3137+
Assert.Equal("With type hierarchies!", deserializedResponseBody!.Child);
3138+
}
3139+
3140+
[Theory]
3141+
[MemberData(nameof(PolymorphicResult))]
3142+
public async Task RequestDelegateWritesMembersFromChildTypesToJsonResponseBody_WithJsonPolymorphicOptionsAndConfiguredJsonOptions(Delegate @delegate)
3143+
{
3144+
var httpContext = CreateHttpContext();
3145+
httpContext.RequestServices = new ServiceCollection()
3146+
.AddSingleton(LoggerFactory)
3147+
.AddSingleton(Options.Create(new JsonOptions()))
3148+
.BuildServiceProvider();
3149+
var responseBodyStream = new MemoryStream();
3150+
httpContext.Response.Body = responseBodyStream;
3151+
3152+
var factoryResult = RequestDelegateFactory.Create(@delegate, new RequestDelegateFactoryOptions { ServiceProvider = httpContext.RequestServices });
3153+
var requestDelegate = factoryResult.RequestDelegate;
3154+
3155+
await requestDelegate(httpContext);
3156+
3157+
var deserializedResponseBody = JsonSerializer.Deserialize<JsonTodoChild>(responseBodyStream.ToArray(), new JsonSerializerOptions
3158+
{
3159+
PropertyNameCaseInsensitive = true
3160+
});
3161+
3162+
Assert.NotNull(deserializedResponseBody);
3163+
Assert.Equal("Write even more tests!", deserializedResponseBody!.Name);
3164+
Assert.Equal("With type hierarchies!", deserializedResponseBody!.Child);
3165+
}
3166+
3167+
[Theory]
3168+
[MemberData(nameof(PolymorphicResult))]
3169+
public async Task RequestDelegateWritesJsonTypeDiscriminatorToJsonResponseBody_WithJsonPolymorphicOptions(Delegate @delegate)
3170+
{
3171+
var httpContext = CreateHttpContext();
3172+
httpContext.RequestServices = new ServiceCollection()
3173+
.AddSingleton(LoggerFactory)
3174+
.AddSingleton(Options.Create(new JsonOptions()))
3175+
.BuildServiceProvider();
3176+
3177+
var responseBodyStream = new MemoryStream();
3178+
httpContext.Response.Body = responseBodyStream;
3179+
3180+
var factoryResult = RequestDelegateFactory.Create(@delegate);
3181+
var requestDelegate = factoryResult.RequestDelegate;
3182+
3183+
await requestDelegate(httpContext);
3184+
3185+
var deserializedResponseBody = JsonNode.Parse(responseBodyStream.ToArray());
3186+
3187+
Assert.NotNull(deserializedResponseBody);
3188+
Assert.NotNull(deserializedResponseBody["$type"]);
3189+
Assert.Equal(nameof(JsonTodoChild), deserializedResponseBody["$type"]!.GetValue<string>());
3190+
}
3191+
30733192
public static IEnumerable<object[]> JsonContextActions
30743193
{
30753194
get
@@ -3110,6 +3229,22 @@ public async Task RequestDelegateWritesAsJsonResponseBody_WithJsonSerializerCont
31103229
Assert.Equal("Write even more tests!", deserializedResponseBody!.Name);
31113230
}
31123231

3232+
[Fact]
3233+
public void CreateDelegateThrows_WhenGetJsonTypeInfoFail()
3234+
{
3235+
var httpContext = CreateHttpContext();
3236+
httpContext.RequestServices = new ServiceCollection()
3237+
.AddSingleton(LoggerFactory)
3238+
.ConfigureHttpJsonOptions(o => o.SerializerOptions.AddContext<TestJsonContext>())
3239+
.BuildServiceProvider();
3240+
3241+
var responseBodyStream = new MemoryStream();
3242+
httpContext.Response.Body = responseBodyStream;
3243+
3244+
TodoStruct TestAction() => new TodoStruct(42, "Bob", true);
3245+
Assert.Throws<NotSupportedException>(() => RequestDelegateFactory.Create(TestAction, new() { ServiceProvider = httpContext.RequestServices }));
3246+
}
3247+
31133248
public static IEnumerable<object[]> CustomResults
31143249
{
31153250
get
@@ -7526,6 +7661,11 @@ private class TodoChild : Todo
75267661
public string? Child { get; set; }
75277662
}
75287663

7664+
private class JsonTodoChild : JsonTodo
7665+
{
7666+
public string? Child { get; set; }
7667+
}
7668+
75297669
private class CustomTodo : Todo
75307670
{
75317671
public static async ValueTask<CustomTodo?> BindAsync(HttpContext context, ParameterInfo parameter)
@@ -7539,6 +7679,8 @@ private class CustomTodo : Todo
75397679
}
75407680
}
75417681

7682+
[JsonPolymorphic]
7683+
[JsonDerivedType(typeof(JsonTodoChild), nameof(JsonTodoChild))]
75427684
private class JsonTodo : Todo
75437685
{
75447686
public static async ValueTask<JsonTodo?> BindAsync(HttpContext context, ParameterInfo parameter)

src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using System.Text;
66
using System.Text.Encodings.Web;
77
using System.Text.Json;
8+
using System.Text.Json.Serialization.Metadata;
9+
using Microsoft.AspNetCore.Http;
810

911
namespace Microsoft.AspNetCore.Mvc.Formatters;
1012

@@ -21,6 +23,8 @@ public SystemTextJsonOutputFormatter(JsonSerializerOptions jsonSerializerOptions
2123
{
2224
SerializerOptions = jsonSerializerOptions;
2325

26+
jsonSerializerOptions.MakeReadOnly();
27+
2428
SupportedEncodings.Add(Encoding.UTF8);
2529
SupportedEncodings.Add(Encoding.Unicode);
2630
SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationJson);
@@ -61,18 +65,27 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon
6165

6266
var httpContext = context.HttpContext;
6367

64-
// context.ObjectType reflects the declared model type when specified.
65-
// For polymorphic scenarios where the user declares a return type, but returns a derived type,
66-
// we want to serialize all the properties on the derived type. This keeps parity with
67-
// the behavior you get when the user does not declare the return type and with Json.Net at least at the top level.
68-
var objectType = context.Object?.GetType() ?? context.ObjectType ?? typeof(object);
68+
var runtimeType = context.Object?.GetType();
69+
JsonTypeInfo? jsonTypeInfo = null;
70+
71+
if (context.ObjectType is not null)
72+
{
73+
var declaredTypeJsonInfo = SerializerOptions.GetTypeInfo(context.ObjectType);
74+
75+
if (declaredTypeJsonInfo.IsPolymorphicSafe() || context.Object is null || runtimeType == declaredTypeJsonInfo.Type)
76+
{
77+
jsonTypeInfo = declaredTypeJsonInfo;
78+
}
79+
}
80+
81+
jsonTypeInfo ??= SerializerOptions.GetTypeInfo(runtimeType ?? typeof(object));
6982

7083
var responseStream = httpContext.Response.Body;
7184
if (selectedEncoding.CodePage == Encoding.UTF8.CodePage)
7285
{
7386
try
7487
{
75-
await JsonSerializer.SerializeAsync(responseStream, context.Object, objectType, SerializerOptions, httpContext.RequestAborted);
88+
await JsonSerializer.SerializeAsync(responseStream, context.Object, jsonTypeInfo, httpContext.RequestAborted);
7689
await responseStream.FlushAsync(httpContext.RequestAborted);
7790
}
7891
catch (OperationCanceledException) when (context.HttpContext.RequestAborted.IsCancellationRequested) { }
@@ -86,7 +99,7 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon
8699
ExceptionDispatchInfo? exceptionDispatchInfo = null;
87100
try
88101
{
89-
await JsonSerializer.SerializeAsync(transcodingStream, context.Object, objectType, SerializerOptions);
102+
await JsonSerializer.SerializeAsync(transcodingStream, context.Object, jsonTypeInfo);
90103
await transcodingStream.FlushAsync();
91104
}
92105
catch (Exception ex)

src/Mvc/Mvc.Core/src/JsonOptions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Text.Json;
5+
using System.Text.Json.Serialization.Metadata;
56
using Microsoft.AspNetCore.Mvc.Formatters;
67
using Microsoft.AspNetCore.Mvc.ModelBinding;
78

@@ -37,5 +38,13 @@ public class JsonOptions
3738
// from deserialization errors that might occur from deeply nested objects.
3839
// This value is the same for model binding and Json.Net's serialization.
3940
MaxDepth = MvcOptions.DefaultMaxModelBindingRecursionDepth,
41+
42+
// The JsonSerializerOptions.GetTypeInfo method is called directly and needs a defined resolver
43+
// setting the default resolver (reflection-based) but the user can overwrite it directly or calling
44+
// .AddContext<TContext>()
45+
TypeInfoResolver = CreateDefaultTypeResolver()
4046
};
47+
48+
private static IJsonTypeInfoResolver CreateDefaultTypeResolver()
49+
=> new DefaultJsonTypeInfoResolver();
4150
}

src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Microsoft.AspNetCore.Mvc.RouteAttribute</Description>
3333
<Compile Include="$(SharedSourceRoot)MediaType\ReadOnlyMediaTypeHeaderValue.cs" LinkBase="Shared" />
3434
<Compile Include="$(SharedSourceRoot)MediaType\HttpTokenParsingRule.cs" LinkBase="Shared" />
3535
<Compile Include="$(SharedSourceRoot)RoutingMetadata\AcceptsMetadata.cs" LinkBase="Shared" />
36+
<Compile Include="$(SharedSourceRoot)Json\JsonSerializerExtensions.cs" LinkBase="Shared"/>
3637
</ItemGroup>
3738

3839
<ItemGroup>

src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ public async Task WriteResponseBodyAsync_Encodes()
123123
var outputFormatterContext = new OutputFormatterWriteContext(
124124
actionContext.HttpContext,
125125
new TestHttpResponseStreamWriterFactory().CreateWriter,
126-
typeof(string),
126+
typeof(object),
127127
content)
128128
{
129129
ContentType = new StringSegment(mediaType.ToString()),

0 commit comments

Comments
 (0)