Skip to content

Commit 2b3789a

Browse files
committed
Stop Calling JSON GetTypeInfo with the runtime type
Calling GetTypeInfo with the object's runtime type doesn't work when using unspeakable types and using JSON source generation. There is no way to mark the unspeakable type (like a C# compiler implemented IAsyncEnumerable iterator) as JsonSerializable. Instead, when the "declared type"'s JsonTypeInfo isn't compatible with the runtime type w.r.t. polymorphism, serialize the value "as object", letting System.Text.Json's serialization take over to serialize the value. Fix dotnet#47548
1 parent 442d656 commit 2b3789a

File tree

9 files changed

+243
-42
lines changed

9 files changed

+243
-42
lines changed

src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ private static Func<HttpContext, StringValues> ResolveFromRouteOrQuery(string pa
149149
""";
150150

151151
public static string WriteToResponseAsyncMethod => """
152+
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
153+
Justification = "<Pending>")]
154+
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "See above.")]
152155
private static Task WriteToResponseAsync<T>(T? value, HttpContext httpContext, JsonTypeInfo<T> jsonTypeInfo, JsonSerializerOptions options)
153156
{
154157
var runtimeType = value?.GetType();
@@ -157,7 +160,7 @@ private static Task WriteToResponseAsync<T>(T? value, HttpContext httpContext, J
157160
return httpContext.Response.WriteAsJsonAsync(value!, jsonTypeInfo);
158161
}
159162
160-
return httpContext.Response.WriteAsJsonAsync(value!, options.GetTypeInfo(runtimeType));
163+
return httpContext.Response.WriteAsJsonAsync<object?>(value, options);
161164
}
162165
""";
163166

src/Http/Http.Extensions/src/HttpResponseJsonExtensions.cs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,16 @@ public static partial class HttpResponseJsonExtensions
3030
/// <param name="value">The value to write as JSON.</param>
3131
/// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
3232
/// <returns>The task object representing the asynchronous operation.</returns>
33+
[RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
34+
[RequiresDynamicCode(RequiresDynamicCodeMessage)]
3335
public static Task WriteAsJsonAsync<TValue>(
3436
this HttpResponse response,
3537
TValue value,
3638
CancellationToken cancellationToken = default)
3739
{
3840
ArgumentNullException.ThrowIfNull(response);
3941

40-
var options = ResolveSerializerOptions(response.HttpContext);
41-
return response.WriteAsJsonAsync(value, jsonTypeInfo: (JsonTypeInfo<TValue>)options.GetTypeInfo(typeof(TValue)), contentType: null, cancellationToken);
42+
return response.WriteAsJsonAsync(value, options: null, contentType: null, cancellationToken);
4243
}
4344

4445
/// <summary>
@@ -108,9 +109,7 @@ public static Task WriteAsJsonAsync<TValue>(
108109
/// <param name="contentType">The content-type to set on the response.</param>
109110
/// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
110111
/// <returns>The task object representing the asynchronous operation.</returns>
111-
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
112112
public static Task WriteAsJsonAsync<TValue>(
113-
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
114113
this HttpResponse response,
115114
TValue value,
116115
JsonTypeInfo<TValue> jsonTypeInfo,
@@ -204,6 +203,8 @@ private static async Task WriteAsJsonAsyncSlow<TValue>(
204203
/// <param name="type">The type of object to write.</param>
205204
/// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
206205
/// <returns>The task object representing the asynchronous operation.</returns>
206+
[RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
207+
[RequiresDynamicCode(RequiresDynamicCodeMessage)]
207208
public static Task WriteAsJsonAsync(
208209
this HttpResponse response,
209210
object? value,
@@ -212,8 +213,7 @@ public static Task WriteAsJsonAsync(
212213
{
213214
ArgumentNullException.ThrowIfNull(response);
214215

215-
var options = ResolveSerializerOptions(response.HttpContext);
216-
return response.WriteAsJsonAsync(value, jsonTypeInfo: options.GetTypeInfo(type), contentType: null, cancellationToken);
216+
return response.WriteAsJsonAsync(value, type, options: null, contentType: null, cancellationToken);
217217
}
218218

219219
/// <summary>
@@ -302,9 +302,7 @@ private static async Task WriteAsJsonAsyncSlow(
302302
/// <param name="contentType">The content-type to set on the response.</param>
303303
/// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the operation.</param>
304304
/// <returns>The task object representing the asynchronous operation.</returns>
305-
#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
306305
public static Task WriteAsJsonAsync(
307-
#pragma warning restore RS0026 // Do not add multiple public overloads with optional parameters
308306
this HttpResponse response,
309307
object? value,
310308
Type type,

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2043,6 +2043,7 @@ public static IEnumerable<object[]> JsonContextActions
20432043

20442044
[JsonSerializable(typeof(Todo))]
20452045
[JsonSerializable(typeof(TodoChild))]
2046+
[JsonSerializable(typeof(IAsyncEnumerable<JsonTodo>))]
20462047
private partial class TestJsonContext : JsonSerializerContext
20472048
{ }
20482049

@@ -2059,7 +2060,8 @@ public async Task RequestDelegateWritesAsJsonResponseBody_WithJsonSerializerCont
20592060
var responseBodyStream = new MemoryStream();
20602061
httpContext.Response.Body = responseBodyStream;
20612062

2062-
var factoryResult = RequestDelegateFactory.Create(@delegate);
2063+
var rdfOptions = new RequestDelegateFactoryOptions() { ServiceProvider = httpContext.RequestServices };
2064+
var factoryResult = RequestDelegateFactory.Create(@delegate, rdfOptions);
20632065
var requestDelegate = factoryResult.RequestDelegate;
20642066

20652067
await requestDelegate(httpContext);
@@ -2073,6 +2075,64 @@ public async Task RequestDelegateWritesAsJsonResponseBody_WithJsonSerializerCont
20732075
Assert.Equal("Write even more tests!", deserializedResponseBody!.Name);
20742076
}
20752077

2078+
[Theory]
2079+
[InlineData(true)]
2080+
[InlineData(false)]
2081+
public async Task RequestDelegateWritesAsJsonResponseBody_UnspeakableType(bool useJsonContext)
2082+
{
2083+
var httpContext = CreateHttpContext();
2084+
httpContext.RequestServices = new ServiceCollection()
2085+
.AddSingleton(LoggerFactory)
2086+
.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolver = useJsonContext ? TestJsonContext.Default : o.SerializerOptions.TypeInfoResolver)
2087+
.BuildServiceProvider();
2088+
2089+
var responseBodyStream = new MemoryStream();
2090+
httpContext.Response.Body = responseBodyStream;
2091+
2092+
Delegate @delegate = IAsyncEnumerable<JsonTodo> () => GetTodosAsync();
2093+
2094+
var rdfOptions = new RequestDelegateFactoryOptions() { ServiceProvider = httpContext.RequestServices };
2095+
var factoryResult = RequestDelegateFactory.Create(@delegate, rdfOptions);
2096+
var requestDelegate = factoryResult.RequestDelegate;
2097+
2098+
await requestDelegate(httpContext);
2099+
2100+
var body = JsonSerializer.Deserialize<JsonTodo[]>(responseBodyStream.ToArray(), new JsonSerializerOptions
2101+
{
2102+
PropertyNameCaseInsensitive = true
2103+
});
2104+
2105+
Assert.NotNull(body);
2106+
Assert.Equal(3, body.Length);
2107+
2108+
var one = Assert.IsType<JsonTodo>(body[0]);
2109+
Assert.Equal(1, one.Id);
2110+
Assert.True(one.IsComplete);
2111+
Assert.Equal("One", one.Name);
2112+
2113+
var two = Assert.IsType<JsonTodo>(body[1]);
2114+
Assert.Equal(2, two.Id);
2115+
Assert.False(two.IsComplete);
2116+
Assert.Equal("Two", two.Name);
2117+
2118+
var three = Assert.IsType<JsonTodoChild>(body[2]);
2119+
Assert.Equal(3, three.Id);
2120+
Assert.True(three.IsComplete);
2121+
Assert.Equal("Three", three.Name);
2122+
Assert.Equal("ThreeChild", three.Child);
2123+
}
2124+
2125+
private static async IAsyncEnumerable<JsonTodo> GetTodosAsync()
2126+
{
2127+
yield return new JsonTodo() { Id = 1, IsComplete = true, Name = "One" };
2128+
2129+
// ensure this is async
2130+
await Task.Yield();
2131+
2132+
yield return new JsonTodo() { Id = 2, IsComplete = false, Name = "Two" };
2133+
yield return new JsonTodoChild() { Id = 3, IsComplete = true, Name = "Three", Child = "ThreeChild" };
2134+
}
2135+
20762136
[Fact]
20772137
public void CreateDelegateThrows_WhenGetJsonTypeInfoFail()
20782138
{

src/Http/Http.Results/src/HttpResultsHelper.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics.CodeAnalysis;
45
using System.Text;
56
using System.Text.Json;
67
using System.Text.Json.Serialization.Metadata;
@@ -19,6 +20,9 @@ internal static partial class HttpResultsHelper
1920
internal const string DefaultContentType = "text/plain; charset=utf-8";
2021
private static readonly Encoding DefaultEncoding = Encoding.UTF8;
2122

23+
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
24+
Justification = "<Pending>")]
25+
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "See above.")]
2226
public static Task WriteResultAsJsonAsync<TValue>(
2327
HttpContext httpContext,
2428
ILogger logger,
@@ -34,8 +38,8 @@ public static Task WriteResultAsJsonAsync<TValue>(
3438
jsonSerializerOptions ??= ResolveJsonOptions(httpContext).SerializerOptions;
3539
var jsonTypeInfo = (JsonTypeInfo<TValue>)jsonSerializerOptions.GetTypeInfo(typeof(TValue));
3640

37-
Type? runtimeType;
38-
if (jsonTypeInfo.IsValid(runtimeType = value.GetType()))
41+
Type? runtimeType = value.GetType();
42+
if (jsonTypeInfo.IsValid(runtimeType))
3943
{
4044
Log.WritingResultAsJson(logger, jsonTypeInfo.Type.Name);
4145
return httpContext.Response.WriteAsJsonAsync(
@@ -46,14 +50,14 @@ public static Task WriteResultAsJsonAsync<TValue>(
4650

4751
Log.WritingResultAsJson(logger, runtimeType.Name);
4852
// Since we don't know the type's polymorphic characteristics
49-
// our best option is use the runtime type, so,
50-
// call WriteAsJsonAsync() with the runtime type to serialize the runtime type rather than the declared type
53+
// our best option is to serialize the value as 'object'.
54+
// call WriteAsJsonAsync<object>() rather than the declared type
5155
// and avoid source generators issues.
5256
// https://github.com/dotnet/aspnetcore/issues/43894
5357
// https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism
54-
return httpContext.Response.WriteAsJsonAsync(
58+
return httpContext.Response.WriteAsJsonAsync<object>(
5559
value,
56-
jsonSerializerOptions.GetTypeInfo(runtimeType),
60+
jsonSerializerOptions,
5761
contentType: contentType);
5862
}
5963

src/Http/Http.Results/test/HttpResultsHelperTests.cs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,58 @@ public async Task WriteResultAsJsonAsync_Works_UsingBaseType_ForChildTypes_WithJ
177177
Assert.Equal("With type hierarchies!", body!.Child);
178178
}
179179

180+
[Theory]
181+
[InlineData(true)]
182+
[InlineData(false)]
183+
public async Task WriteResultAsJsonAsync_Works_UsingUnspeakableType(bool useJsonContext)
184+
{
185+
// Arrange
186+
var value = GetTodosAsync();
187+
var responseBodyStream = new MemoryStream();
188+
var httpContext = CreateHttpContext(responseBodyStream);
189+
var serializerOptions = new JsonOptions().SerializerOptions;
190+
191+
if (useJsonContext)
192+
{
193+
serializerOptions.TypeInfoResolver = TestJsonContext.Default;
194+
}
195+
196+
// Act
197+
await HttpResultsHelper.WriteResultAsJsonAsync(httpContext, NullLogger.Instance, value, jsonSerializerOptions: serializerOptions);
198+
199+
// Assert
200+
var body = JsonSerializer.Deserialize<JsonTodo[]>(responseBodyStream.ToArray(), serializerOptions);
201+
202+
Assert.Equal(3, body.Length);
203+
204+
var one = Assert.IsType<JsonTodo>(body[0]);
205+
Assert.Equal(1, one.Id);
206+
Assert.True(one.IsComplete);
207+
Assert.Equal("One", one.Name);
208+
209+
var two = Assert.IsType<JsonTodo>(body[1]);
210+
Assert.Equal(2, two.Id);
211+
Assert.False(two.IsComplete);
212+
Assert.Equal("Two", two.Name);
213+
214+
var three = Assert.IsType<TodoJsonChild>(body[2]);
215+
Assert.Equal(3, three.Id);
216+
Assert.True(three.IsComplete);
217+
Assert.Equal("Three", three.Name);
218+
Assert.Equal("ThreeChild", three.Child);
219+
}
220+
221+
private static async IAsyncEnumerable<JsonTodo> GetTodosAsync()
222+
{
223+
yield return new JsonTodo() { Id = 1, IsComplete = true, Name = "One" };
224+
225+
// ensure this is async
226+
await Task.Yield();
227+
228+
yield return new JsonTodo() { Id = 2, IsComplete = false, Name = "Two" };
229+
yield return new TodoJsonChild() { Id = 3, IsComplete = true, Name = "Three", Child = "ThreeChild" };
230+
}
231+
180232
private static DefaultHttpContext CreateHttpContext(Stream stream)
181233
=> new()
182234
{
@@ -198,6 +250,8 @@ private static IServiceProvider CreateServices()
198250
[JsonSerializable(typeof(TodoChild))]
199251
[JsonSerializable(typeof(JsonTodo))]
200252
[JsonSerializable(typeof(TodoStruct))]
253+
[JsonSerializable(typeof(IAsyncEnumerable<JsonTodo>))]
254+
[JsonSerializable(typeof(JsonTodo[]))]
201255
private partial class TestJsonContext : JsonSerializerContext
202256
{ }
203257

@@ -224,7 +278,7 @@ private class TodoChild : Todo
224278
public string Child { get; set; }
225279
}
226280

227-
[JsonDerivedType(typeof(TodoJsonChild))]
281+
[JsonDerivedType(typeof(TodoJsonChild), nameof(TodoJsonChild))]
228282
private class JsonTodo : Todo
229283
{
230284
}

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,27 +65,38 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon
6565

6666
var httpContext = context.HttpContext;
6767

68-
var runtimeType = context.Object?.GetType();
68+
// context.ObjectType reflects the declared model type when specified.
69+
// For polymorphic scenarios where the user declares a return type, but returns a derived type,
70+
// we want to serialize all the properties on the derived type. This keeps parity with
71+
// the behavior you get when the user does not declare the return type.
72+
// To enable this our best option is to check if the JsonTypeInfo for the declared type is valid,
73+
// if it is use it. If it isn't, serialize the value as 'object' and let JsonSerializer serialize it as necessary.
6974
JsonTypeInfo? jsonTypeInfo = null;
70-
7175
if (context.ObjectType is not null)
7276
{
7377
var declaredTypeJsonInfo = SerializerOptions.GetTypeInfo(context.ObjectType);
7478

79+
var runtimeType = context.Object?.GetType();
7580
if (declaredTypeJsonInfo.IsValid(runtimeType))
7681
{
7782
jsonTypeInfo = declaredTypeJsonInfo;
7883
}
7984
}
8085

81-
jsonTypeInfo ??= SerializerOptions.GetTypeInfo(runtimeType ?? typeof(object));
82-
8386
var responseStream = httpContext.Response.Body;
8487
if (selectedEncoding.CodePage == Encoding.UTF8.CodePage)
8588
{
8689
try
8790
{
88-
await JsonSerializer.SerializeAsync(responseStream, context.Object, jsonTypeInfo, httpContext.RequestAborted);
91+
if (jsonTypeInfo is not null)
92+
{
93+
await JsonSerializer.SerializeAsync(responseStream, context.Object, jsonTypeInfo, httpContext.RequestAborted);
94+
}
95+
else
96+
{
97+
await JsonSerializer.SerializeAsync(responseStream, context.Object, SerializerOptions, httpContext.RequestAborted);
98+
}
99+
89100
await responseStream.FlushAsync(httpContext.RequestAborted);
90101
}
91102
catch (OperationCanceledException) when (context.HttpContext.RequestAborted.IsCancellationRequested) { }
@@ -99,7 +110,15 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon
99110
ExceptionDispatchInfo? exceptionDispatchInfo = null;
100111
try
101112
{
102-
await JsonSerializer.SerializeAsync(transcodingStream, context.Object, jsonTypeInfo);
113+
if (jsonTypeInfo is not null)
114+
{
115+
await JsonSerializer.SerializeAsync(transcodingStream, context.Object, jsonTypeInfo);
116+
}
117+
else
118+
{
119+
await JsonSerializer.SerializeAsync(transcodingStream, context.Object, SerializerOptions);
120+
}
121+
103122
await transcodingStream.FlushAsync();
104123
}
105124
catch (Exception ex)

0 commit comments

Comments
 (0)