Skip to content

Commit f0bd9db

Browse files
JsonSerializerOptions.Web for JsonSerializerOptions (#94370)
* feat: add web json serializer option * fix: change always returns the same published instance * fix: changed creation method use JsonSerializerOptions constructor with JsonSerializerDefaults and add tests * Address pending feedback and tidy up DefaultJsonTypeInfoResolver rooting logic. * Dogfood new API in System.Net.Http.Json. --------- Co-authored-by: Eirik Tsarpalis <[email protected]>
1 parent fe79ce8 commit f0bd9db

File tree

9 files changed

+97
-44
lines changed

9 files changed

+97
-44
lines changed

src/libraries/System.Net.Http.Json/src/System/Net/Http/Json/HttpClientJsonExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ public static partial class HttpClientJsonExtensions
1717
[RequiresUnreferencedCode(HttpContentJsonExtensions.SerializationUnreferencedCodeMessage)]
1818
[RequiresDynamicCode(HttpContentJsonExtensions.SerializationDynamicCodeMessage)]
1919
private static Task<object?> FromJsonAsyncCore(Func<HttpClient, Uri?, CancellationToken, Task<HttpResponseMessage>> getMethod, HttpClient client, Uri? requestUri, Type type, JsonSerializerOptions? options, CancellationToken cancellationToken = default) =>
20-
FromJsonAsyncCore(getMethod, client, requestUri, static (stream, options, cancellation) => JsonSerializer.DeserializeAsync(stream, options.type, options.options ?? JsonHelpers.s_defaultSerializerOptions, cancellation), (type, options), cancellationToken);
20+
FromJsonAsyncCore(getMethod, client, requestUri, static (stream, options, cancellation) => JsonSerializer.DeserializeAsync(stream, options.type, options.options ?? JsonSerializerOptions.Web, cancellation), (type, options), cancellationToken);
2121

2222
[RequiresUnreferencedCode(HttpContentJsonExtensions.SerializationUnreferencedCodeMessage)]
2323
[RequiresDynamicCode(HttpContentJsonExtensions.SerializationDynamicCodeMessage)]
2424
private static Task<TValue?> FromJsonAsyncCore<TValue>(Func<HttpClient, Uri?, CancellationToken, Task<HttpResponseMessage>> getMethod, HttpClient client, Uri? requestUri, JsonSerializerOptions? options, CancellationToken cancellationToken = default) =>
25-
FromJsonAsyncCore(getMethod, client, requestUri, static (stream, options, cancellation) => JsonSerializer.DeserializeAsync<TValue>(stream, options ?? JsonHelpers.s_defaultSerializerOptions, cancellation), options, cancellationToken);
25+
FromJsonAsyncCore(getMethod, client, requestUri, static (stream, options, cancellation) => JsonSerializer.DeserializeAsync<TValue>(stream, options ?? JsonSerializerOptions.Web, cancellation), options, cancellationToken);
2626

2727
private static Task<object?> FromJsonAsyncCore(Func<HttpClient, Uri?, CancellationToken, Task<HttpResponseMessage>> getMethod, HttpClient client, Uri? requestUri, Type type, JsonSerializerContext context, CancellationToken cancellationToken = default) =>
2828
FromJsonAsyncCore(getMethod, client, requestUri, static (stream, options, cancellation) => JsonSerializer.DeserializeAsync(stream, options.type, options.context, cancellation), (type, context), cancellationToken);

src/libraries/System.Net.Http.Json/src/System/Net/Http/Json/HttpContentJsonExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public static partial class HttpContentJsonExtensions
9191
{
9292
using (Stream contentStream = await GetContentStreamAsync(content, cancellationToken).ConfigureAwait(false))
9393
{
94-
return await JsonSerializer.DeserializeAsync(contentStream, type, options ?? JsonHelpers.s_defaultSerializerOptions, cancellationToken).ConfigureAwait(false);
94+
return await JsonSerializer.DeserializeAsync(contentStream, type, options ?? JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false);
9595
}
9696
}
9797

@@ -101,7 +101,7 @@ public static partial class HttpContentJsonExtensions
101101
{
102102
using (Stream contentStream = await GetContentStreamAsync(content, cancellationToken).ConfigureAwait(false))
103103
{
104-
return await JsonSerializer.DeserializeAsync<T>(contentStream, options ?? JsonHelpers.s_defaultSerializerOptions, cancellationToken).ConfigureAwait(false);
104+
return await JsonSerializer.DeserializeAsync<T>(contentStream, options ?? JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false);
105105
}
106106
}
107107

src/libraries/System.Net.Http.Json/src/System/Net/Http/Json/JsonHelpers.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ namespace System.Net.Http.Json
1212
{
1313
internal static class JsonHelpers
1414
{
15-
internal static readonly JsonSerializerOptions s_defaultSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
16-
1715
[RequiresUnreferencedCode(HttpContentJsonExtensions.SerializationUnreferencedCodeMessage)]
1816
[RequiresDynamicCode(HttpContentJsonExtensions.SerializationDynamicCodeMessage)]
1917
internal static JsonTypeInfo GetJsonTypeInfo(Type type, JsonSerializerOptions? options)
@@ -22,7 +20,7 @@ internal static JsonTypeInfo GetJsonTypeInfo(Type type, JsonSerializerOptions? o
2220

2321
// Resolves JsonTypeInfo metadata using the appropriate JsonSerializerOptions configuration,
2422
// following the semantics of the JsonSerializer reflection methods.
25-
options ??= s_defaultSerializerOptions;
23+
options ??= JsonSerializerOptions.Web;
2624
options.MakeReadOnly(populateMissingResolver: true);
2725
return options.GetTypeInfo(type);
2826
}

src/libraries/System.Text.Json/ref/System.Text.Json.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ public JsonSerializerOptions(System.Text.Json.JsonSerializerOptions options) { }
371371
public bool AllowTrailingCommas { get { throw null; } set { } }
372372
public System.Collections.Generic.IList<System.Text.Json.Serialization.JsonConverter> Converters { get { throw null; } }
373373
public static System.Text.Json.JsonSerializerOptions Default { [System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."), System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")] get { throw null; } }
374+
public static System.Text.Json.JsonSerializerOptions Web { [System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."), System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")] get { throw null; } }
374375
public int DefaultBufferSize { get { throw null; } set { } }
375376
public System.Text.Json.Serialization.JsonIgnoreCondition DefaultIgnoreCondition { get { throw null; } set { } }
376377
public System.Text.Json.JsonNamingPolicy? DictionaryKeyPolicy { get { throw null; } set { } }

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,32 @@ public static JsonSerializerOptions Default
4141
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
4242
get
4343
{
44-
if (s_defaultOptions is not JsonSerializerOptions options)
45-
{
46-
options = GetOrCreateDefaultOptionsInstance();
47-
}
48-
49-
return options;
44+
return s_defaultOptions ?? GetOrCreateSingleton(ref s_defaultOptions, JsonSerializerDefaults.General);
5045
}
5146
}
5247

5348
private static JsonSerializerOptions? s_defaultOptions;
5449

50+
/// <summary>
51+
/// Gets a read-only, singleton instance of <see cref="JsonSerializerOptions" /> that uses the web configuration.
52+
/// </summary>
53+
/// <remarks>
54+
/// Each <see cref="JsonSerializerOptions" /> instance encapsulates its own serialization metadata caches,
55+
/// so using fresh default instances every time one is needed can result in redundant recomputation of converters.
56+
/// This property provides a shared instance that can be consumed by any number of components without necessitating any converter recomputation.
57+
/// </remarks>
58+
public static JsonSerializerOptions Web
59+
{
60+
[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
61+
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
62+
get
63+
{
64+
return s_webOptions ?? GetOrCreateSingleton(ref s_webOptions, JsonSerializerDefaults.Web);
65+
}
66+
}
67+
68+
private static JsonSerializerOptions? s_webOptions;
69+
5570
// For any new option added, adding it to the options copied in the copy constructor below must be considered.
5671
private IJsonTypeInfoResolver? _typeInfoResolver;
5772
private JsonNamingPolicy? _dictionaryKeyPolicy;
@@ -752,7 +767,7 @@ private void ConfigureForJsonSerializer()
752767
{
753768
// Even if a resolver has already been specified, we need to root
754769
// the default resolver to gain access to the default converters.
755-
DefaultJsonTypeInfoResolver defaultResolver = DefaultJsonTypeInfoResolver.RootDefaultInstance();
770+
DefaultJsonTypeInfoResolver defaultResolver = DefaultJsonTypeInfoResolver.DefaultInstance;
756771

757772
switch (_typeInfoResolver)
758773
{
@@ -938,22 +953,24 @@ protected override void OnCollectionModifying()
938953

939954
[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
940955
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
941-
private static JsonSerializerOptions GetOrCreateDefaultOptionsInstance()
956+
private static JsonSerializerOptions GetOrCreateSingleton(
957+
ref JsonSerializerOptions? location,
958+
JsonSerializerDefaults defaults)
942959
{
943-
var options = new JsonSerializerOptions
960+
var options = new JsonSerializerOptions(defaults)
944961
{
945962
// Because we're marking the default instance as read-only,
946963
// we need to specify a resolver instance for the case where
947964
// reflection is disabled by default: use one that returns null for all types.
948965

949966
TypeInfoResolver = JsonSerializer.IsReflectionEnabledByDefault
950-
? DefaultJsonTypeInfoResolver.RootDefaultInstance()
967+
? DefaultJsonTypeInfoResolver.DefaultInstance
951968
: JsonTypeInfoResolver.Empty,
952969

953970
_isReadOnly = true,
954971
};
955972

956-
return Interlocked.CompareExchange(ref s_defaultOptions, options, null) ?? options;
973+
return Interlocked.CompareExchange(ref location, options, null) ?? options;
957974
}
958975

959976
[DebuggerBrowsable(DebuggerBrowsableState.Never)]

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Converters.cs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ public partial class DefaultJsonTypeInfoResolver
1919
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
2020
private static JsonConverterFactory[] GetDefaultFactoryConverters()
2121
{
22-
return new JsonConverterFactory[]
23-
{
22+
return
23+
[
2424
// Check for disallowed types.
2525
new UnsupportedTypeConverterFactory(),
2626
// Nullable converter should always be next since it forwards to any nullable type.
@@ -35,7 +35,7 @@ private static JsonConverterFactory[] GetDefaultFactoryConverters()
3535
new IEnumerableConverterFactory(),
3636
// Object should always be last since it converts any type.
3737
new ObjectConverterFactory()
38-
};
38+
];
3939
}
4040

4141
private static Dictionary<Type, JsonConverter> GetDefaultSimpleConverters()
@@ -89,13 +89,14 @@ void Add(JsonConverter converter) =>
8989
converters.Add(converter.Type!, converter);
9090
}
9191

92+
[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
93+
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
9294
private static JsonConverter GetBuiltInConverter(Type typeToConvert)
9395
{
94-
Debug.Assert(s_defaultSimpleConverters != null);
95-
Debug.Assert(s_defaultFactoryConverters != null);
96+
s_defaultSimpleConverters ??= GetDefaultSimpleConverters();
97+
s_defaultFactoryConverters ??= GetDefaultFactoryConverters();
9698

97-
JsonConverter? converter;
98-
if (s_defaultSimpleConverters.TryGetValue(typeToConvert, out converter))
99+
if (s_defaultSimpleConverters.TryGetValue(typeToConvert, out JsonConverter? converter))
99100
{
100101
return converter;
101102
}
@@ -142,8 +143,6 @@ internal static bool TryGetDefaultSimpleConverter(Type typeToConvert, [NotNullWh
142143
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
143144
internal static JsonConverter GetConverterForType(Type typeToConvert, JsonSerializerOptions options, bool resolveJsonConverterAttribute = true)
144145
{
145-
RootDefaultInstance(); // Ensure default converters are rooted.
146-
147146
// Priority 1: Attempt to get custom converter from the Converters list.
148147
JsonConverter? converter = options.GetConverterFromList(typeToConvert);
149148

src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.cs

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ public DefaultJsonTypeInfoResolver() : this(mutable: true)
3131
private DefaultJsonTypeInfoResolver(bool mutable)
3232
{
3333
_mutable = mutable;
34-
35-
s_defaultFactoryConverters ??= GetDefaultFactoryConverters();
36-
s_defaultSimpleConverters ??= GetDefaultSimpleConverters();
3734
}
3835

3936
/// <summary>
@@ -127,21 +124,22 @@ bool IBuiltInJsonTypeInfoResolver.IsCompatibleWithOptions(JsonSerializerOptions
127124
// provided that no user extensions have been made on the class.
128125
=> _modifiers is null or { Count: 0 } && GetType() == typeof(DefaultJsonTypeInfoResolver);
129126

130-
internal static bool IsDefaultInstanceRooted => s_defaultInstance is not null;
131-
private static DefaultJsonTypeInfoResolver? s_defaultInstance;
132-
133-
[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
134-
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
135-
internal static DefaultJsonTypeInfoResolver RootDefaultInstance()
127+
internal static DefaultJsonTypeInfoResolver DefaultInstance
136128
{
137-
if (s_defaultInstance is DefaultJsonTypeInfoResolver result)
129+
[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
130+
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
131+
get
138132
{
139-
return result;
140-
}
133+
if (s_defaultInstance is DefaultJsonTypeInfoResolver result)
134+
{
135+
return result;
136+
}
141137

142-
var newInstance = new DefaultJsonTypeInfoResolver(mutable: false);
143-
DefaultJsonTypeInfoResolver? originalInstance = Interlocked.CompareExchange(ref s_defaultInstance, newInstance, comparand: null);
144-
return originalInstance ?? newInstance;
138+
var newInstance = new DefaultJsonTypeInfoResolver(mutable: false);
139+
return Interlocked.CompareExchange(ref s_defaultInstance, newInstance, comparand: null) ?? newInstance;
140+
}
145141
}
142+
143+
private static DefaultJsonTypeInfoResolver? s_defaultInstance;
146144
}
147145
}

src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/CacheTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,6 @@ static Func<JsonSerializerOptions, int> CreateCacheCountAccessor()
257257

258258
[ActiveIssue("https://github.com/dotnet/runtime/issues/66232", TargetFrameworkMonikers.NetFramework)]
259259
[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
260-
[MemberData(nameof(GetJsonSerializerOptions))]
261260
public static void JsonSerializerOptions_ReuseConverterCaches()
262261
{
263262
// This test uses reflection to:

src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/OptionsTests.cs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,7 @@ public static void Options_JsonSerializerContext_Net6CompatibilitySwitch_FallsBa
713713
["System.Text.Json.Serialization.EnableSourceGenReflectionFallback"] = true
714714
}
715715
};
716-
716+
717717
RemoteExecutor.Invoke(static () =>
718718
{
719719
var unsupportedValue = new MyClass { Value = "value" };
@@ -1036,6 +1036,47 @@ public static void JsonSerializerOptions_Default_IsReadOnly()
10361036
Assert.Same(resolver, optionsSingleton.TypeInfoResolver);
10371037
}
10381038

1039+
[Fact]
1040+
public static void JsonSerializerOptions_Web_MatchesConstructorWithJsonSerializerDefaults()
1041+
{
1042+
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
1043+
{
1044+
TypeInfoResolver = JsonSerializerOptions.Default.TypeInfoResolver
1045+
};
1046+
1047+
JsonSerializerOptions optionsSingleton = JsonSerializerOptions.Web;
1048+
1049+
Assert.Equal(JsonNamingPolicy.CamelCase, options.PropertyNamingPolicy);
1050+
Assert.True(options.PropertyNameCaseInsensitive);
1051+
Assert.Equal(JsonNumberHandling.AllowReadingFromString, options.NumberHandling);
1052+
1053+
JsonTestHelper.AssertOptionsEqual(options, optionsSingleton);
1054+
}
1055+
1056+
[Fact]
1057+
public static void JsonSerializerOptions_Web_SerializesWithExpectedSettings()
1058+
{
1059+
string json = JsonSerializer.Serialize(new { PropertyName = 42 }, JsonSerializerOptions.Web);
1060+
Assert.Equal("""{"propertyName":42}""", json);
1061+
}
1062+
1063+
[Fact]
1064+
public static void JsonSerializerOptions_Web_ReturnsSameInstance()
1065+
{
1066+
Assert.Same(JsonSerializerOptions.Web, JsonSerializerOptions.Web);
1067+
}
1068+
1069+
[Fact]
1070+
public static void JsonSerializerOptions_Web_IsReadOnly()
1071+
{
1072+
var optionsSingleton = JsonSerializerOptions.Web;
1073+
Assert.True(optionsSingleton.IsReadOnly);
1074+
Assert.Throws<InvalidOperationException>(() => optionsSingleton.PropertyNameCaseInsensitive = true);
1075+
Assert.Throws<InvalidOperationException>(() => optionsSingleton.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
1076+
Assert.Throws<InvalidOperationException>(() => optionsSingleton.NumberHandling = JsonNumberHandling.AllowReadingFromString);
1077+
Assert.Throws<InvalidOperationException>(() => new JsonContext(optionsSingleton));
1078+
}
1079+
10391080
[Theory]
10401081
[MemberData(nameof(GetInitialTypeInfoResolversAndExpectedChains))]
10411082
public static void TypeInfoResolverChain_SetTypeInfoResolver_ReturnsExpectedChain(

0 commit comments

Comments
 (0)