Skip to content

Commit

Permalink
JsonSerializerOptions.Web for JsonSerializerOptions (#94370)
Browse files Browse the repository at this point in the history
* 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 <eirik.tsarpalis@gmail.com>
  • Loading branch information
I-SER-I and eiriktsarpalis authored Nov 28, 2023
1 parent fe79ce8 commit f0bd9db
Show file tree
Hide file tree
Showing 9 changed files with 97 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ public static partial class HttpClientJsonExtensions
[RequiresUnreferencedCode(HttpContentJsonExtensions.SerializationUnreferencedCodeMessage)]
[RequiresDynamicCode(HttpContentJsonExtensions.SerializationDynamicCodeMessage)]
private static Task<object?> FromJsonAsyncCore(Func<HttpClient, Uri?, CancellationToken, Task<HttpResponseMessage>> getMethod, HttpClient client, Uri? requestUri, Type type, JsonSerializerOptions? options, CancellationToken cancellationToken = default) =>
FromJsonAsyncCore(getMethod, client, requestUri, static (stream, options, cancellation) => JsonSerializer.DeserializeAsync(stream, options.type, options.options ?? JsonHelpers.s_defaultSerializerOptions, cancellation), (type, options), cancellationToken);
FromJsonAsyncCore(getMethod, client, requestUri, static (stream, options, cancellation) => JsonSerializer.DeserializeAsync(stream, options.type, options.options ?? JsonSerializerOptions.Web, cancellation), (type, options), cancellationToken);

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

private static Task<object?> FromJsonAsyncCore(Func<HttpClient, Uri?, CancellationToken, Task<HttpResponseMessage>> getMethod, HttpClient client, Uri? requestUri, Type type, JsonSerializerContext context, CancellationToken cancellationToken = default) =>
FromJsonAsyncCore(getMethod, client, requestUri, static (stream, options, cancellation) => JsonSerializer.DeserializeAsync(stream, options.type, options.context, cancellation), (type, context), cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public static partial class HttpContentJsonExtensions
{
using (Stream contentStream = await GetContentStreamAsync(content, cancellationToken).ConfigureAwait(false))
{
return await JsonSerializer.DeserializeAsync(contentStream, type, options ?? JsonHelpers.s_defaultSerializerOptions, cancellationToken).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync(contentStream, type, options ?? JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false);
}
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ namespace System.Net.Http.Json
{
internal static class JsonHelpers
{
internal static readonly JsonSerializerOptions s_defaultSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);

[RequiresUnreferencedCode(HttpContentJsonExtensions.SerializationUnreferencedCodeMessage)]
[RequiresDynamicCode(HttpContentJsonExtensions.SerializationDynamicCodeMessage)]
internal static JsonTypeInfo GetJsonTypeInfo(Type type, JsonSerializerOptions? options)
Expand All @@ -22,7 +20,7 @@ internal static JsonTypeInfo GetJsonTypeInfo(Type type, JsonSerializerOptions? o

// Resolves JsonTypeInfo metadata using the appropriate JsonSerializerOptions configuration,
// following the semantics of the JsonSerializer reflection methods.
options ??= s_defaultSerializerOptions;
options ??= JsonSerializerOptions.Web;
options.MakeReadOnly(populateMissingResolver: true);
return options.GetTypeInfo(type);
}
Expand Down
1 change: 1 addition & 0 deletions src/libraries/System.Text.Json/ref/System.Text.Json.cs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ public JsonSerializerOptions(System.Text.Json.JsonSerializerOptions options) { }
public bool AllowTrailingCommas { get { throw null; } set { } }
public System.Collections.Generic.IList<System.Text.Json.Serialization.JsonConverter> Converters { get { throw null; } }
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; } }
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; } }
public int DefaultBufferSize { get { throw null; } set { } }
public System.Text.Json.Serialization.JsonIgnoreCondition DefaultIgnoreCondition { get { throw null; } set { } }
public System.Text.Json.JsonNamingPolicy? DictionaryKeyPolicy { get { throw null; } set { } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,32 @@ public static JsonSerializerOptions Default
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
get
{
if (s_defaultOptions is not JsonSerializerOptions options)
{
options = GetOrCreateDefaultOptionsInstance();
}

return options;
return s_defaultOptions ?? GetOrCreateSingleton(ref s_defaultOptions, JsonSerializerDefaults.General);
}
}

private static JsonSerializerOptions? s_defaultOptions;

/// <summary>
/// Gets a read-only, singleton instance of <see cref="JsonSerializerOptions" /> that uses the web configuration.
/// </summary>
/// <remarks>
/// Each <see cref="JsonSerializerOptions" /> instance encapsulates its own serialization metadata caches,
/// so using fresh default instances every time one is needed can result in redundant recomputation of converters.
/// This property provides a shared instance that can be consumed by any number of components without necessitating any converter recomputation.
/// </remarks>
public static JsonSerializerOptions Web
{
[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
get
{
return s_webOptions ?? GetOrCreateSingleton(ref s_webOptions, JsonSerializerDefaults.Web);
}
}

private static JsonSerializerOptions? s_webOptions;

// For any new option added, adding it to the options copied in the copy constructor below must be considered.
private IJsonTypeInfoResolver? _typeInfoResolver;
private JsonNamingPolicy? _dictionaryKeyPolicy;
Expand Down Expand Up @@ -752,7 +767,7 @@ private void ConfigureForJsonSerializer()
{
// Even if a resolver has already been specified, we need to root
// the default resolver to gain access to the default converters.
DefaultJsonTypeInfoResolver defaultResolver = DefaultJsonTypeInfoResolver.RootDefaultInstance();
DefaultJsonTypeInfoResolver defaultResolver = DefaultJsonTypeInfoResolver.DefaultInstance;

switch (_typeInfoResolver)
{
Expand Down Expand Up @@ -938,22 +953,24 @@ protected override void OnCollectionModifying()

[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
private static JsonSerializerOptions GetOrCreateDefaultOptionsInstance()
private static JsonSerializerOptions GetOrCreateSingleton(
ref JsonSerializerOptions? location,
JsonSerializerDefaults defaults)
{
var options = new JsonSerializerOptions
var options = new JsonSerializerOptions(defaults)
{
// Because we're marking the default instance as read-only,
// we need to specify a resolver instance for the case where
// reflection is disabled by default: use one that returns null for all types.

TypeInfoResolver = JsonSerializer.IsReflectionEnabledByDefault
? DefaultJsonTypeInfoResolver.RootDefaultInstance()
? DefaultJsonTypeInfoResolver.DefaultInstance
: JsonTypeInfoResolver.Empty,

_isReadOnly = true,
};

return Interlocked.CompareExchange(ref s_defaultOptions, options, null) ?? options;
return Interlocked.CompareExchange(ref location, options, null) ?? options;
}

[DebuggerBrowsable(DebuggerBrowsableState.Never)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ public partial class DefaultJsonTypeInfoResolver
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
private static JsonConverterFactory[] GetDefaultFactoryConverters()
{
return new JsonConverterFactory[]
{
return
[
// Check for disallowed types.
new UnsupportedTypeConverterFactory(),
// Nullable converter should always be next since it forwards to any nullable type.
Expand All @@ -35,7 +35,7 @@ private static JsonConverterFactory[] GetDefaultFactoryConverters()
new IEnumerableConverterFactory(),
// Object should always be last since it converts any type.
new ObjectConverterFactory()
};
];
}

private static Dictionary<Type, JsonConverter> GetDefaultSimpleConverters()
Expand Down Expand Up @@ -89,13 +89,14 @@ void Add(JsonConverter converter) =>
converters.Add(converter.Type!, converter);
}

[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
private static JsonConverter GetBuiltInConverter(Type typeToConvert)
{
Debug.Assert(s_defaultSimpleConverters != null);
Debug.Assert(s_defaultFactoryConverters != null);
s_defaultSimpleConverters ??= GetDefaultSimpleConverters();
s_defaultFactoryConverters ??= GetDefaultFactoryConverters();

JsonConverter? converter;
if (s_defaultSimpleConverters.TryGetValue(typeToConvert, out converter))
if (s_defaultSimpleConverters.TryGetValue(typeToConvert, out JsonConverter? converter))
{
return converter;
}
Expand Down Expand Up @@ -142,8 +143,6 @@ internal static bool TryGetDefaultSimpleConverter(Type typeToConvert, [NotNullWh
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
internal static JsonConverter GetConverterForType(Type typeToConvert, JsonSerializerOptions options, bool resolveJsonConverterAttribute = true)
{
RootDefaultInstance(); // Ensure default converters are rooted.

// Priority 1: Attempt to get custom converter from the Converters list.
JsonConverter? converter = options.GetConverterFromList(typeToConvert);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ public DefaultJsonTypeInfoResolver() : this(mutable: true)
private DefaultJsonTypeInfoResolver(bool mutable)
{
_mutable = mutable;

s_defaultFactoryConverters ??= GetDefaultFactoryConverters();
s_defaultSimpleConverters ??= GetDefaultSimpleConverters();
}

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

internal static bool IsDefaultInstanceRooted => s_defaultInstance is not null;
private static DefaultJsonTypeInfoResolver? s_defaultInstance;

[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
internal static DefaultJsonTypeInfoResolver RootDefaultInstance()
internal static DefaultJsonTypeInfoResolver DefaultInstance
{
if (s_defaultInstance is DefaultJsonTypeInfoResolver result)
[RequiresUnreferencedCode(JsonSerializer.SerializationUnreferencedCodeMessage)]
[RequiresDynamicCode(JsonSerializer.SerializationRequiresDynamicCodeMessage)]
get
{
return result;
}
if (s_defaultInstance is DefaultJsonTypeInfoResolver result)
{
return result;
}

var newInstance = new DefaultJsonTypeInfoResolver(mutable: false);
DefaultJsonTypeInfoResolver? originalInstance = Interlocked.CompareExchange(ref s_defaultInstance, newInstance, comparand: null);
return originalInstance ?? newInstance;
var newInstance = new DefaultJsonTypeInfoResolver(mutable: false);
return Interlocked.CompareExchange(ref s_defaultInstance, newInstance, comparand: null) ?? newInstance;
}
}

private static DefaultJsonTypeInfoResolver? s_defaultInstance;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,6 @@ static Func<JsonSerializerOptions, int> CreateCacheCountAccessor()

[ActiveIssue("https://github.com/dotnet/runtime/issues/66232", TargetFrameworkMonikers.NetFramework)]
[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
[MemberData(nameof(GetJsonSerializerOptions))]
public static void JsonSerializerOptions_ReuseConverterCaches()
{
// This test uses reflection to:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ public static void Options_JsonSerializerContext_Net6CompatibilitySwitch_FallsBa
["System.Text.Json.Serialization.EnableSourceGenReflectionFallback"] = true
}
};

RemoteExecutor.Invoke(static () =>
{
var unsupportedValue = new MyClass { Value = "value" };
Expand Down Expand Up @@ -1036,6 +1036,47 @@ public static void JsonSerializerOptions_Default_IsReadOnly()
Assert.Same(resolver, optionsSingleton.TypeInfoResolver);
}

[Fact]
public static void JsonSerializerOptions_Web_MatchesConstructorWithJsonSerializerDefaults()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
TypeInfoResolver = JsonSerializerOptions.Default.TypeInfoResolver
};

JsonSerializerOptions optionsSingleton = JsonSerializerOptions.Web;

Assert.Equal(JsonNamingPolicy.CamelCase, options.PropertyNamingPolicy);
Assert.True(options.PropertyNameCaseInsensitive);
Assert.Equal(JsonNumberHandling.AllowReadingFromString, options.NumberHandling);

JsonTestHelper.AssertOptionsEqual(options, optionsSingleton);
}

[Fact]
public static void JsonSerializerOptions_Web_SerializesWithExpectedSettings()
{
string json = JsonSerializer.Serialize(new { PropertyName = 42 }, JsonSerializerOptions.Web);
Assert.Equal("""{"propertyName":42}""", json);
}

[Fact]
public static void JsonSerializerOptions_Web_ReturnsSameInstance()
{
Assert.Same(JsonSerializerOptions.Web, JsonSerializerOptions.Web);
}

[Fact]
public static void JsonSerializerOptions_Web_IsReadOnly()
{
var optionsSingleton = JsonSerializerOptions.Web;
Assert.True(optionsSingleton.IsReadOnly);
Assert.Throws<InvalidOperationException>(() => optionsSingleton.PropertyNameCaseInsensitive = true);
Assert.Throws<InvalidOperationException>(() => optionsSingleton.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
Assert.Throws<InvalidOperationException>(() => optionsSingleton.NumberHandling = JsonNumberHandling.AllowReadingFromString);
Assert.Throws<InvalidOperationException>(() => new JsonContext(optionsSingleton));
}

[Theory]
[MemberData(nameof(GetInitialTypeInfoResolversAndExpectedChains))]
public static void TypeInfoResolverChain_SetTypeInfoResolver_ReturnsExpectedChain(
Expand Down

0 comments on commit f0bd9db

Please sign in to comment.