Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/libraries/System.Text.Json/ref/System.Text.Json.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,7 @@ public enum JsonTypeInfoKind
}
public static partial class JsonTypeInfoResolver
{
public static System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver Combine(params System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver[] resolvers) { throw null; }
public static System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver Combine(params System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver?[] resolvers) { throw null; }
}
public abstract partial class JsonTypeInfo<T> : System.Text.Json.Serialization.Metadata.JsonTypeInfo
{
Expand Down
3 changes: 0 additions & 3 deletions src/libraries/System.Text.Json/src/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -659,9 +659,6 @@
<data name="CreateObjectConverterNotCompatible" xml:space="preserve">
<value>The converter for type '{0}' does not support setting 'CreateObject' delegates.</value>
</data>
<data name="CombineOneOfResolversIsNull" xml:space="preserve">
<value>One of the provided resolvers is null.</value>
</data>
<data name="JsonPropertyInfoBoundToDifferentParent" xml:space="preserve">
<value>JsonPropertyInfo with name '{0}' for type '{1}' is already bound to different JsonTypeInfo.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,6 @@ public JsonSerializerOptions Options

return options;
}

internal set
{
Debug.Assert(!value.IsReadOnly);
value.TypeInfoResolver = this;
value.MakeReadOnly();
_options = value;
}
}

/// <summary>
Expand Down Expand Up @@ -94,7 +86,9 @@ protected JsonSerializerContext(JsonSerializerOptions? options)
if (options != null)
{
options.VerifyMutable();
Options = options;
options.TypeInfoResolver = this;
options.MakeReadOnly();
_options = options;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,18 +161,22 @@ public JsonSerializerOptions(JsonSerializerDefaults defaults) : this()
}

/// <summary>
/// Binds current <see cref="JsonSerializerOptions"/> instance with a new instance of the specified <see cref="Serialization.JsonSerializerContext"/> type.
/// Appends a <see cref="Serialization.JsonSerializerContext"/> to the metadata resolution of the current <see cref="JsonSerializerOptions"/> instance.
/// </summary>
/// <typeparam name="TContext">The generic definition of the specified context type.</typeparam>
/// <remarks>
/// When serializing and deserializing types using the options
/// instance, metadata for the types will be fetched from the context instance.
///
/// The methods supports adding multiple contexts per options instance.
/// Metadata will be resolved in the order of configuration, similar to
/// how <see cref="JsonTypeInfoResolver.Combine(IJsonTypeInfoResolver?[])"/> resolves metadata.
/// </remarks>
public void AddContext<TContext>() where TContext : JsonSerializerContext, new()
{
VerifyMutable();
TContext context = new();
context.Options = this;
TypeInfoResolver = JsonTypeInfoResolver.Combine(TypeInfoResolver, context);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;

namespace System.Text.Json.Serialization.Metadata
{
/// <summary>
Expand All @@ -13,7 +15,7 @@ public static class JsonTypeInfoResolver
/// </summary>
/// <param name="resolvers">Sequence of contract resolvers to be queried for metadata.</param>
/// <returns>A <see cref="IJsonTypeInfoResolver"/> combining results from <paramref name="resolvers"/>.</returns>
/// <exception cref="ArgumentException"><paramref name="resolvers"/> or any of its elements is null.</exception>
/// <exception cref="ArgumentException"><paramref name="resolvers"/> is null.</exception>
/// <remarks>
/// The combined resolver will query each of <paramref name="resolvers"/> in the specified order,
/// returning the first result that is non-null. If all <paramref name="resolvers"/> return null,
Expand All @@ -23,32 +25,42 @@ public static class JsonTypeInfoResolver
/// which typically define contract metadata for small subsets of types.
/// It can also be used to fall back to <see cref="DefaultJsonTypeInfoResolver"/> wherever necessary.
/// </remarks>
public static IJsonTypeInfoResolver Combine(params IJsonTypeInfoResolver[] resolvers)
public static IJsonTypeInfoResolver Combine(params IJsonTypeInfoResolver?[] resolvers)
{
if (resolvers == null)
if (resolvers is null)
{
throw new ArgumentNullException(nameof(resolvers));
}

var flattenedResolvers = new List<IJsonTypeInfoResolver>();

foreach (IJsonTypeInfoResolver? resolver in resolvers)
{
if (resolver == null)
if (resolver is null)
{
continue;
}
else if (resolver is CombiningJsonTypeInfoResolver nested)
{
throw new ArgumentNullException(nameof(resolvers), SR.CombineOneOfResolversIsNull);
flattenedResolvers.AddRange(nested._resolvers);
}
else
{
flattenedResolvers.Add(resolver);
}
}

return new CombiningJsonTypeInfoResolver(resolvers);
return flattenedResolvers.Count == 1
? flattenedResolvers[0]
: new CombiningJsonTypeInfoResolver(flattenedResolvers.ToArray());
}

private sealed class CombiningJsonTypeInfoResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver[] _resolvers;
internal readonly IJsonTypeInfoResolver[] _resolvers;

public CombiningJsonTypeInfoResolver(IJsonTypeInfoResolver[] resolvers)
{
_resolvers = resolvers.AsSpan().ToArray();
}
=> _resolvers = resolvers;

public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Xunit;

Expand All @@ -14,18 +16,39 @@ namespace System.Text.Json.Serialization.Tests
public static partial class JsonTypeInfoResolverTests
{
[Fact]
public static void GetTypeInfoNullArguments()
public static void CombineNullArgument()
{
IJsonTypeInfoResolver[] resolvers = null;
Assert.Throws<ArgumentNullException>(() => JsonTypeInfoResolver.Combine(resolvers));
}

[Fact]
public static void Combine_ShouldFlattenResolvers()
{
DefaultJsonTypeInfoResolver nonNullResolver1 = new();
DefaultJsonTypeInfoResolver nonNullResolver2 = new();
Assert.Throws<ArgumentNullException>(() => JsonTypeInfoResolver.Combine(null));
Assert.Throws<ArgumentNullException>(() => JsonTypeInfoResolver.Combine(null, null));
Assert.Throws<ArgumentNullException>(() => JsonTypeInfoResolver.Combine(nonNullResolver1, null));
Assert.Throws<ArgumentNullException>(() => JsonTypeInfoResolver.Combine(nonNullResolver1, nonNullResolver2, null));
Assert.Throws<ArgumentNullException>(() => JsonTypeInfoResolver.Combine(nonNullResolver1, null, nonNullResolver2));
DefaultJsonTypeInfoResolver nonNullResolver3 = new();

ValidateCombinations(Array.Empty<IJsonTypeInfoResolver>(), JsonTypeInfoResolver.Combine());
ValidateCombinations(Array.Empty<IJsonTypeInfoResolver>(), JsonTypeInfoResolver.Combine(new IJsonTypeInfoResolver[] { null }));
ValidateCombinations(Array.Empty<IJsonTypeInfoResolver>(), JsonTypeInfoResolver.Combine(null, null));
ValidateCombinations(new[] { nonNullResolver1 }, JsonTypeInfoResolver.Combine(nonNullResolver1, null));
ValidateCombinations(new[] { nonNullResolver1, nonNullResolver2 }, JsonTypeInfoResolver.Combine(nonNullResolver1, nonNullResolver2, null));
ValidateCombinations(new[] { nonNullResolver1, nonNullResolver2 }, JsonTypeInfoResolver.Combine(nonNullResolver1, null, nonNullResolver2));
ValidateCombinations(new[] { nonNullResolver1, nonNullResolver2, nonNullResolver3 }, JsonTypeInfoResolver.Combine(JsonTypeInfoResolver.Combine(JsonTypeInfoResolver.Combine(nonNullResolver1), nonNullResolver2), nonNullResolver3));
ValidateCombinations(new[] { nonNullResolver1, nonNullResolver2, nonNullResolver3 }, JsonTypeInfoResolver.Combine(JsonTypeInfoResolver.Combine(nonNullResolver1, null, nonNullResolver2), nonNullResolver3));

static void ValidateCombinations(IJsonTypeInfoResolver[] expectedResolvers, IJsonTypeInfoResolver combinedResolver)
{
if (expectedResolvers.Length == 1)
{
Assert.Same(expectedResolvers[0], combinedResolver);
}
else
{
Assert.Equal(expectedResolvers, GetCombinedResolvers(combinedResolver));
}
}
}

[Fact]
Expand Down Expand Up @@ -126,5 +149,24 @@ public static void CombiningUsesAndRespectsAllResolversInOrder()
Assert.Null(combined.GetTypeInfo(typeof(StringBuilder), options));
Assert.Equal(4, resolverId);
}

private static IJsonTypeInfoResolver[] GetCombinedResolvers(IJsonTypeInfoResolver resolver)
{
(Type combinedResolverType, FieldInfo underlyingResolverField) = s_combinedResolverMembers.Value;
Assert.IsType(combinedResolverType, resolver);
return (IJsonTypeInfoResolver[])underlyingResolverField.GetValue(resolver);
}

private static Lazy<(Type, FieldInfo)> s_combinedResolverMembers = new Lazy<(Type, FieldInfo)>
(
static () =>
{
Type? combinedResolverType = typeof(JsonTypeInfoResolver).GetNestedType("CombiningJsonTypeInfoResolver", BindingFlags.NonPublic);
Assert.NotNull(combinedResolverType);
FieldInfo underlyingResolverField = combinedResolverType.GetField("_resolvers", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(underlyingResolverField);
return (combinedResolverType, underlyingResolverField);
}
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,30 @@ public void AddContext()
{
JsonSerializerOptions options = new();
options.AddContext<MyJsonContext>();
Assert.IsType<MyJsonContext>(options.TypeInfoResolver);
}

[Fact]
public void AddContext_SupportsMultipleContexts()
{
JsonSerializerOptions options = new();
options.AddContext<SingleTypeContext<int>>();
options.AddContext<SingleTypeContext<string>>();

Assert.NotNull(options.GetTypeInfo(typeof(int)));
Assert.NotNull(options.GetTypeInfo(typeof(string)));
Assert.Throws<NotSupportedException>(() => options.GetTypeInfo(typeof(bool)));
}

[Fact]
public void AddContext_AppendsToExistingResolver()
{
JsonSerializerOptions options = new();
options.TypeInfoResolver = new DefaultJsonTypeInfoResolver();
options.AddContext<MyJsonContext>(); // this context always throws

// Options can be binded only once.
CauseInvalidOperationException(() => options.AddContext<MyJsonContext>());
CauseInvalidOperationException(() => options.AddContext<MyJsonContextThatSetsOptionsInParameterlessCtor>());
// should always consult the default resolver, never falling back to the throwing resolver.
options.GetTypeInfo(typeof(int));
}

private static void CauseInvalidOperationException(Action action)
Expand All @@ -48,16 +68,15 @@ public void AddContextOverwritesOptionsForFreshContext()
{
// Context binds with options when instantiated with parameterless ctor.
MyJsonContextThatSetsOptionsInParameterlessCtor context = new();
FieldInfo optionsField = typeof(JsonSerializerContext).GetField("_options", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(optionsField);
Assert.NotNull((JsonSerializerOptions)optionsField.GetValue(context));
Assert.NotNull(context.Options);
Assert.Same(context, context.Options.TypeInfoResolver);

// Those options are overwritten when context is binded via options.AddContext<TContext>();
JsonSerializerOptions options = new();
Assert.Null(options.TypeInfoResolver);
options.AddContext<MyJsonContextThatSetsOptionsInParameterlessCtor>(); // No error.
FieldInfo resolverField = typeof(JsonSerializerOptions).GetField("_typeInfoResolver", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(resolverField);
Assert.Same(options, ((JsonSerializerContext)resolverField.GetValue(options)).Options);
Assert.NotNull(options.TypeInfoResolver);
Assert.NotSame(options, ((JsonSerializerContext)options.TypeInfoResolver).Options);
}

[Fact]
Expand All @@ -66,25 +85,26 @@ public void AlreadyBindedOptions()
// Bind the options.
JsonSerializerOptions options = new();
options.AddContext<MyJsonContext>();
Assert.False(options.IsReadOnly);

// Attempt to bind the instance again.
Assert.Throws<InvalidOperationException>(() => new MyJsonContext(options));
// Pass the options to a context constructor
_ = new MyJsonContext(options);
Assert.True(options.IsReadOnly);
}

[Fact]
public void OptionsImmutableAfterBinding()
public void OptionsMutableAfterBinding()
{
// Bind via AddContext
JsonSerializerOptions options = new();
options.PropertyNameCaseInsensitive = true;
options.AddContext<MyJsonContext>();
CauseInvalidOperationException(() => options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
Assert.False(options.IsReadOnly);

// Bind via context ctor
options = new JsonSerializerOptions();
MyJsonContext context = new MyJsonContext(options);
Assert.Same(options, context.Options);
CauseInvalidOperationException(() => options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
Assert.True(options.IsReadOnly);
}

[Fact]
Expand Down Expand Up @@ -130,5 +150,13 @@ public EmptyContext(JsonSerializerOptions options) : base(options) { }
protected override JsonSerializerOptions? GeneratedSerializerOptions => null;
public override JsonTypeInfo? GetTypeInfo(Type type) => JsonTypeInfo.CreateJsonTypeInfo(type, Options);
}

private class SingleTypeContext<T> : JsonSerializerContext, IJsonTypeInfoResolver
{
public SingleTypeContext() : base(null) { }
protected override JsonSerializerOptions? GeneratedSerializerOptions => null;
public override JsonTypeInfo? GetTypeInfo(Type type) => GetTypeInfo(type, Options);
public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) => type == typeof(T) ? JsonTypeInfo.CreateJsonTypeInfo(type, options) : null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,16 +172,16 @@ public static void NewDefaultOptions_MakeReadOnly_NoTypeInfoResolver_ThrowsInval
}

[Fact]
public static void TypeInfoResolverCannotBeSetAfterAddingContext()
public static void TypeInfoResolverCanBeSetAfterAddingContext()
{
var options = new JsonSerializerOptions();
Assert.False(options.IsReadOnly);

options.AddContext<JsonContext>();
Assert.True(options.IsReadOnly);
Assert.False(options.IsReadOnly);

Assert.IsType<JsonContext>(options.TypeInfoResolver);
Assert.Throws<InvalidOperationException>(() => options.TypeInfoResolver = new DefaultJsonTypeInfoResolver());
options.TypeInfoResolver = new DefaultJsonTypeInfoResolver();
Assert.IsType<DefaultJsonTypeInfoResolver>(options.TypeInfoResolver);
}

[Fact]
Expand All @@ -194,19 +194,21 @@ public static void TypeInfoResolverCannotBeSetOnOptionsCreatedFromContext()
}

[Fact]
public static void WhenAddingContextTypeInfoResolverAsContextOptionsAreSameAsOptions()
public static void WhenAddingContextTypeInfoResolverAsContextOptionsAreNotSameAsOptions()
{
var options = new JsonSerializerOptions();
options.AddContext<JsonContext>();
Assert.Same(options, (options.TypeInfoResolver as JsonContext).Options);
Assert.NotSame(options, (options.TypeInfoResolver as JsonContext).Options);
}

[Fact]
public static void WhenAddingContext_SettingResolverToNullThrowsInvalidOperationException()
public static void WhenAddingContext_CanSetResolverToNull()
{
var options = new JsonSerializerOptions();
options.AddContext<JsonContext>();
Assert.Throws<InvalidOperationException>(() => options.TypeInfoResolver = null);

options.TypeInfoResolver = null;
Assert.Null(options.TypeInfoResolver);
}

[Fact]
Expand Down