Skip to content

Commit 7c0091a

Browse files
authored
Use Json TypeInfoResolverChain (#47450)
* Use Json TypeInfoResolverChain - Removes all usages of JsonSerializerOptions.AddContext, which will be obsoleted by dotnet/runtime#83280 - Update ProblemDetailsJsonContext to be added in a Configure<JsonOptions>() call back and to always be added to the beginning of the resolver chain at that time - This gives us the simplest, most understandable pattern for all libraries to follow. - Small clean up in the API template's usings * Change ProblemDetailsJsonOptionsSetup to use Configure instead of PostConfigure. When adding the ProblemDetailsJsonContext, we always prepend it to the beginning of the chain at the time the configure step is executed, no matter the state of the chain. This allows for a simpler, more understandable policy for all libraries that want to add their JsonContext into the JsonSerializerOptions resolver chain. The order of the chain is now determined by the order that the configure steps were registered in DI (for example when AddProblemDetails() was called).
1 parent 61787bc commit 7c0091a

File tree

8 files changed

+110
-49
lines changed

8 files changed

+110
-49
lines changed

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
using System.Text.Json.Serialization.Metadata;
77
using Microsoft.AspNetCore.Internal;
88

9-
#nullable enable
10-
119
namespace Microsoft.AspNetCore.Http.Json;
1210

1311
/// <summary>
@@ -18,23 +16,23 @@ public class JsonOptions
1816
{
1917
internal static readonly JsonSerializerOptions DefaultSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
2018
{
21-
// Web defaults don't use the relex JSON escaping encoder.
19+
// Web defaults don't use the relaxed JSON escaping encoder.
2220
//
2321
// Because these options are for producing content that is written directly to the request
2422
// (and not embedded in an HTML page for example), we can use UnsafeRelaxedJsonEscaping.
2523
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
2624

2725
// The JsonSerializerOptions.GetTypeInfo method is called directly and needs a defined resolver
28-
// setting the default resolver (reflection-based) but the user can overwrite it directly or calling
29-
// .AddContext<TContext>()
26+
// setting the default resolver (reflection-based) but the user can overwrite it directly or by modifying
27+
// the TypeInfoResolverChain
3028
TypeInfoResolver = TrimmingAppContextSwitches.EnsureJsonTrimmability ? null : CreateDefaultTypeResolver()
3129
};
3230

3331
// Use a copy so the defaults are not modified.
3432
/// <summary>
3533
/// Gets the <see cref="JsonSerializerOptions"/>.
3634
/// </summary>
37-
public JsonSerializerOptions SerializerOptions { get; internal set; } = new JsonSerializerOptions(DefaultSerializerOptions);
35+
public JsonSerializerOptions SerializerOptions { get; } = new JsonSerializerOptions(DefaultSerializerOptions);
3836

3937
#pragma warning disable IL2026 // Suppressed in Microsoft.AspNetCore.Http.Extensions.WarningSuppressions.xml
4038
#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.
Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,26 @@
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.Text.Json.Serialization.Metadata;
54
using Microsoft.AspNetCore.Http.Json;
65
using Microsoft.Extensions.Options;
76

87
namespace Microsoft.AspNetCore.Http;
98

10-
internal sealed class ProblemDetailsJsonOptionsSetup : IPostConfigureOptions<JsonOptions>
9+
/// <summary>
10+
/// Adds the ProblemDetailsJsonContext to the current JsonSerializerOptions.
11+
///
12+
/// This allows for consistent serialization behavior for ProblemDetails regardless if
13+
/// the default reflection-based serializer is used or not. And makes it trim/NativeAOT compatible.
14+
/// </summary>
15+
internal sealed class ProblemDetailsJsonOptionsSetup : IConfigureOptions<JsonOptions>
1116
{
12-
public void PostConfigure(string? name, JsonOptions options)
17+
public void Configure(JsonOptions options)
1318
{
14-
switch (options.SerializerOptions.TypeInfoResolver)
15-
{
16-
case DefaultJsonTypeInfoResolver:
17-
// In this case, the current configuration is using a reflection-based resolver
18-
// and we are prepending our internal problem details context to be evaluated
19-
// first.
20-
options.SerializerOptions.TypeInfoResolver = JsonTypeInfoResolver.Combine(ProblemDetailsJsonContext.Default, options.SerializerOptions.TypeInfoResolver);
21-
break;
22-
case not null:
23-
// Combine the current resolver with our internal problem details context (adding last)
24-
options.SerializerOptions.AddContext<ProblemDetailsJsonContext>();
25-
break;
26-
default:
27-
// Not adding our source gen context when TypeInfoResolver == null
28-
// since adding it will skip the reflection-based resolver and potentially
29-
// cause unexpected serialization problems
30-
break;
31-
}
19+
// Always insert the ProblemDetailsJsonContext to the beginning of the chain at the time
20+
// this Configure is invoked. This JsonTypeInfoResolver will be before the default reflection-based resolver,
21+
// and before any other resolvers currently added.
22+
// If apps need to customize ProblemDetails serialization, they can prepend a custom ProblemDetails resolver
23+
// to the chain in an IConfigureOptions<JsonOptions> registered after the call to AddProblemDetails().
24+
options.SerializerOptions.TypeInfoResolverChain.Insert(0, new ProblemDetailsJsonContext());
3225
}
3326
}

src/Http/Http.Extensions/src/ProblemDetailsServiceCollectionExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ public static IServiceCollection AddProblemDetails(
4040
// Adding default services;
4141
services.TryAddSingleton<IProblemDetailsService, ProblemDetailsService>();
4242
services.TryAddEnumerable(ServiceDescriptor.Singleton<IProblemDetailsWriter, DefaultProblemDetailsWriter>());
43-
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<JsonOptions>, ProblemDetailsJsonOptionsSetup>());
43+
// Use IConfigureOptions (instead of post-configure) so the registration gets added/invoked relative to when AddProblemDetails() is called.
44+
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<JsonOptions>, ProblemDetailsJsonOptionsSetup>());
4445

4546
if (configure != null)
4647
{

src/Http/Http.Extensions/test/ProblemDetailsServiceCollectionExtensionsTest.cs

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
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.Text.Json;
45
using System.Text.Json.Serialization;
56
using System.Text.Json.Serialization.Metadata;
6-
using Microsoft.AspNetCore.Http.Json;
77
using Microsoft.AspNetCore.Mvc;
88
using Microsoft.Extensions.DependencyInjection;
99
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -27,7 +27,7 @@ public void AddProblemDetails_AddsNeededServices()
2727
// Assert
2828
Assert.Single(collection, (sd) => sd.ServiceType == typeof(IProblemDetailsService) && sd.ImplementationType == typeof(ProblemDetailsService));
2929
Assert.Single(collection, (sd) => sd.ServiceType == typeof(IProblemDetailsWriter) && sd.ImplementationType == typeof(DefaultProblemDetailsWriter));
30-
Assert.Single(collection, (sd) => sd.ServiceType == typeof(IPostConfigureOptions<JsonOptions>) && sd.ImplementationType == typeof(ProblemDetailsJsonOptionsSetup));
30+
Assert.Single(collection, (sd) => sd.ServiceType == typeof(IConfigureOptions<JsonOptions>) && sd.ImplementationType == typeof(ProblemDetailsJsonOptionsSetup));
3131
}
3232

3333
[Fact]
@@ -43,7 +43,7 @@ public void AddProblemDetails_DoesNotDuplicate_WhenMultipleCalls()
4343
// Assert
4444
Assert.Single(collection, (sd) => sd.ServiceType == typeof(IProblemDetailsService) && sd.ImplementationType == typeof(ProblemDetailsService));
4545
Assert.Single(collection, (sd) => sd.ServiceType == typeof(IProblemDetailsWriter) && sd.ImplementationType == typeof(DefaultProblemDetailsWriter));
46-
Assert.Single(collection, (sd) => sd.ServiceType == typeof(IPostConfigureOptions<JsonOptions>) && sd.ImplementationType == typeof(ProblemDetailsJsonOptionsSetup));
46+
Assert.Single(collection, (sd) => sd.ServiceType == typeof(IConfigureOptions<JsonOptions>) && sd.ImplementationType == typeof(ProblemDetailsJsonOptionsSetup));
4747
}
4848

4949
[Fact]
@@ -109,7 +109,8 @@ public void AddProblemDetails_Throws_ForReadOnlyJsonOptions()
109109
// Arrange
110110
var collection = new ServiceCollection();
111111
collection.AddOptions<JsonOptions>();
112-
collection.ConfigureAll<JsonOptions>(options => {
112+
collection.ConfigureAll<JsonOptions>(options =>
113+
{
113114
options.SerializerOptions.TypeInfoResolver = new TestExtensionsJsonContext();
114115
options.SerializerOptions.MakeReadOnly();
115116
});
@@ -124,13 +125,35 @@ public void AddProblemDetails_Throws_ForReadOnlyJsonOptions()
124125
Assert.Throws<InvalidOperationException>(() => jsonOptions.Value);
125126
}
126127

127-
[Fact]
128-
public void AddProblemDetails_CombinesProblemDetailsContext_WhenAddContext()
128+
public enum CustomContextBehavior
129+
{
130+
Prepend,
131+
Append,
132+
Replace,
133+
}
134+
135+
[Theory]
136+
[InlineData(CustomContextBehavior.Prepend)]
137+
[InlineData(CustomContextBehavior.Append)]
138+
[InlineData(CustomContextBehavior.Replace)]
139+
public void AddProblemDetails_CombinesProblemDetailsContext_WhenAddingCustomContext(CustomContextBehavior behavior)
129140
{
130141
// Arrange
131142
var collection = new ServiceCollection();
132143
collection.AddOptions<JsonOptions>();
133-
collection.ConfigureAll<JsonOptions>(options => options.SerializerOptions.AddContext<TestExtensionsJsonContext>());
144+
145+
if (behavior == CustomContextBehavior.Prepend)
146+
{
147+
collection.ConfigureAll<JsonOptions>(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, TestExtensionsJsonContext.Default));
148+
}
149+
else if (behavior == CustomContextBehavior.Append)
150+
{
151+
collection.ConfigureAll<JsonOptions>(options => options.SerializerOptions.TypeInfoResolverChain.Add(TestExtensionsJsonContext.Default));
152+
}
153+
else
154+
{
155+
collection.ConfigureAll<JsonOptions>(options => options.SerializerOptions.TypeInfoResolver = TestExtensionsJsonContext.Default);
156+
}
134157

135158
// Act
136159
collection.AddProblemDetails();
@@ -146,7 +169,7 @@ public void AddProblemDetails_CombinesProblemDetailsContext_WhenAddContext()
146169
}
147170

148171
[Fact]
149-
public void AddProblemDetails_DoesNotCombineProblemDetailsContext_WhenNullTypeInfoResolver()
172+
public void AddProblemDetails_CombinesProblemDetailsContext_EvenWhenNullTypeInfoResolver()
150173
{
151174
// Arrange
152175
var collection = new ServiceCollection();
@@ -161,7 +184,8 @@ public void AddProblemDetails_DoesNotCombineProblemDetailsContext_WhenNullTypeIn
161184
var jsonOptions = services.GetService<IOptions<JsonOptions>>();
162185

163186
Assert.NotNull(jsonOptions.Value);
164-
Assert.Null(jsonOptions.Value.SerializerOptions.TypeInfoResolver);
187+
Assert.NotNull(jsonOptions.Value.SerializerOptions.TypeInfoResolver);
188+
Assert.NotNull(jsonOptions.Value.SerializerOptions.TypeInfoResolver.GetTypeInfo(typeof(ProblemDetails), jsonOptions.Value.SerializerOptions));
165189
}
166190

167191
[Fact]
@@ -186,9 +210,55 @@ public void AddProblemDetails_CombineProblemDetailsContext_WhenDefaultTypeInfoRe
186210
Assert.NotNull(jsonOptions.Value.SerializerOptions.TypeInfoResolver.GetTypeInfo(typeof(TypeA), jsonOptions.Value.SerializerOptions));
187211
}
188212

213+
[Fact]
214+
public void AddProblemDetails_CanHaveCustomJsonTypeInfo()
215+
{
216+
// Arrange
217+
var collection = new ServiceCollection();
218+
collection.AddOptions<JsonOptions>();
219+
220+
// Act
221+
collection.AddProblemDetails();
222+
223+
// add any custom ProblemDetails TypeInfoResolvers after calling AddProblemDetails()
224+
var customProblemDetailsResolver = new CustomProblemDetailsTypeInfoResolver();
225+
collection.ConfigureAll<JsonOptions>(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, customProblemDetailsResolver));
226+
227+
// Assert
228+
var services = collection.BuildServiceProvider();
229+
var jsonOptions = services.GetService<IOptions<JsonOptions>>();
230+
231+
Assert.NotNull(jsonOptions.Value);
232+
Assert.NotNull(jsonOptions.Value.SerializerOptions.TypeInfoResolver);
233+
234+
Assert.Equal(3, jsonOptions.Value.SerializerOptions.TypeInfoResolverChain.Count);
235+
Assert.IsType<CustomProblemDetailsTypeInfoResolver>(jsonOptions.Value.SerializerOptions.TypeInfoResolverChain[0]);
236+
Assert.Equal("Microsoft.AspNetCore.Http.ProblemDetailsJsonContext", jsonOptions.Value.SerializerOptions.TypeInfoResolverChain[1].GetType().FullName);
237+
Assert.IsType<DefaultJsonTypeInfoResolver>(jsonOptions.Value.SerializerOptions.TypeInfoResolverChain[2]);
238+
239+
var pdTypeInfo = jsonOptions.Value.SerializerOptions.GetTypeInfo(typeof(ProblemDetails));
240+
Assert.Same(customProblemDetailsResolver.LastProblemDetailsInfo, pdTypeInfo);
241+
}
242+
189243
[JsonSerializable(typeof(TypeA))]
190244
internal partial class TestExtensionsJsonContext : JsonSerializerContext
191245
{ }
192246

193247
public class TypeA { }
248+
249+
internal class CustomProblemDetailsTypeInfoResolver : IJsonTypeInfoResolver
250+
{
251+
public JsonTypeInfo LastProblemDetailsInfo { get; set; }
252+
253+
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
254+
{
255+
if (type == typeof(ProblemDetails))
256+
{
257+
LastProblemDetailsInfo = JsonTypeInfo.CreateJsonTypeInfo<ProblemDetails>(options);
258+
return LastProblemDetailsInfo;
259+
}
260+
261+
return null;
262+
}
263+
}
194264
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public async Task WriteResultAsJsonAsync_Works_ForValueTypes(bool useJsonContext
3030

3131
if (useJsonContext)
3232
{
33-
serializerOptions.AddContext<TestJsonContext>();
33+
serializerOptions.TypeInfoResolver = TestJsonContext.Default;
3434
}
3535

3636
// Act
@@ -61,7 +61,7 @@ public async Task WriteResultAsJsonAsync_Works_ForReferenceTypes(bool useJsonCon
6161

6262
if (useJsonContext)
6363
{
64-
serializerOptions.AddContext<TestJsonContext>();
64+
serializerOptions.TypeInfoResolver = TestJsonContext.Default;
6565
}
6666

6767
// Act
@@ -94,7 +94,7 @@ public async Task WriteResultAsJsonAsync_Works_ForChildTypes(bool useJsonContext
9494

9595
if (useJsonContext)
9696
{
97-
serializerOptions.AddContext<TestJsonContext>();
97+
serializerOptions.TypeInfoResolver = TestJsonContext.Default;
9898
}
9999

100100
// Act
@@ -128,7 +128,7 @@ public async Task WriteResultAsJsonAsync_Works_UsingBaseType_ForChildTypes(bool
128128

129129
if (useJsonContext)
130130
{
131-
serializerOptions.AddContext<TestJsonContext>();
131+
serializerOptions.TypeInfoResolver = TestJsonContext.Default;
132132
}
133133

134134
// Act
@@ -162,7 +162,7 @@ public async Task WriteResultAsJsonAsync_Works_UsingBaseType_ForChildTypes_WithJ
162162

163163
if (useJsonContext)
164164
{
165-
serializerOptions.AddContext<TestJsonContext>();
165+
serializerOptions.TypeInfoResolver = TestJsonContext.Default;
166166
}
167167

168168
// Act

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ public class JsonOptions
4141
MaxDepth = MvcOptions.DefaultMaxModelBindingRecursionDepth,
4242

4343
// The JsonSerializerOptions.GetTypeInfo method is called directly and needs a defined resolver
44-
// setting the default resolver (reflection-based) but the user can overwrite it directly or calling
45-
// .AddContext<TContext>()
44+
// setting the default resolver (reflection-based) but the user can overwrite it directly or by modifying
45+
// the TypeInfoResolverChain
4646
TypeInfoResolver = TrimmingAppContextSwitches.EnsureJsonTrimmability ? null : CreateDefaultTypeResolver()
4747
};
4848

src/ProjectTemplates/Web.ProjectTemplates/content/Api-CSharp/Program.Main.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
using System.Text.Json.Serialization;
21
#if NativeAot
3-
using Microsoft.AspNetCore.Http.Json;
4-
using Microsoft.Extensions.Options;
2+
using System.Text.Json.Serialization;
3+
54
#endif
65
namespace Company.ApiApplication1;
76

@@ -15,7 +14,7 @@ public static void Main(string[] args)
1514
#if (NativeAot)
1615
builder.Services.ConfigureHttpJsonOptions(options =>
1716
{
18-
options.SerializerOptions.AddContext<AppJsonSerializerContext>();
17+
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
1918
});
2019

2120
#endif

src/ProjectTemplates/Web.ProjectTemplates/content/Api-CSharp/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
#if (NativeAot)
1010
builder.Services.ConfigureHttpJsonOptions(options =>
1111
{
12-
options.SerializerOptions.AddContext<AppJsonSerializerContext>();
12+
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
1313
});
1414

1515
#endif

0 commit comments

Comments
 (0)