Skip to content

Internal json converters cannot function independently of their original options #50205

@NinoFloris

Description

@NinoFloris

Internal json collection converters for instance assume JsonClassInfo.ElementInfo is not null, however it can actually be null if the converter has been pre-aquired from another options instance.

JsonClassInfo elementClassInfo = state.Current.JsonClassInfo.ElementClassInfo!;

ElementInfo returns null if ElementType is null and ElementType will only be filled under specific circumstances.

When we start at the point of our custom converter's Read() method (see below) we get the following steps to an NRE:

  • pre-aquired JsonConverter.Read will initialize its own state (readstack) with among others a call to options.GetOrAddClassForRootType(type)
  • This will then do a resolve for the type and its converter in the constructor of JsonClassInfo(type).
  • Converter, which is resolved back again to the custom converter here will have ClassType.None because all custom converters do.
  • State is now initialized with ElementType = null because it didn't fall into the ClassType.Collection arm
  • Call to OnTryRead commences with state that will never return anything but null for JsonClassInfo.ElementInfo
  • NRE thrown
using System;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace StjRepro
{
    public enum Cases
    {
        One,
        Two,
        Three,
        Four
    }

    class JsonImmutableArrayConverter<T> : JsonConverter<ImmutableArray<T>>
    {
        JsonConverter<ImmutableArray<T>> _originalConverter;

        public JsonImmutableArrayConverter()
            => _originalConverter = (JsonConverter<ImmutableArray<T>>)new JsonSerializerOptions().GetConverter(typeof(ImmutableArray<T>));

        public override ImmutableArray<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            // We're passing the current options and not the default options because we do want our value converters to work
            // in this example, from strings to the enum 'Cases'.
            => _originalConverter.Read(ref reader, typeToConvert, options);

        public override void Write(Utf8JsonWriter writer, ImmutableArray<T> value, JsonSerializerOptions options)
        {
            throw new NotSupportedException();
        }
    }

    class JsonImmutableArrayConverter : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
            => typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(ImmutableArray<>);

        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
            => (JsonConverter)Activator.CreateInstance(typeof(JsonImmutableArrayConverter<>).MakeGenericType(typeToConvert.GenericTypeArguments[0]));
    }

    class Program
    {
        static void Main(string[] args)
        {
            var options = new JsonSerializerOptions();
            options.Converters.Add(new JsonImmutableArrayConverter());
            options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
            JsonSerializer.Deserialize<ImmutableArray<Cases>>(@"[""one"",""two"",""three"",""four""]", options);
        }
    }
}
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at System.Text.Json.Serialization.Converters.IEnumerableDefaultConverter`2.GetElementConverter(JsonClassInfo elementClassInfo)
   at System.Text.Json.Serialization.Converters.IEnumerableDefaultConverter`2.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, TCollection& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonResumableConverter`1.Read(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options)
   at StjRepro.JsonImmutableArrayConverter`1.Read(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadCore[TValue](JsonConverter jsonConverter, Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadCore[TValue](Utf8JsonReader& reader, Type returnType, JsonSerializerOptions options)
   at System.Text.Json.JsonSerializer.Deserialize[TValue](ReadOnlySpan`1 json, Type returnType, JsonSerializerOptions options)
   at System.Text.Json.JsonSerializer.Deserialize[TValue](String json, JsonSerializerOptions options)
   at StjRepro.Program.Main(String[] args)

The reason for pulling out an 'original' converter like this is because some code paths in a custom converter should just be able to default to existing converters, merely wrapping over them.

The only way I see to hack around this without a proper fix is passing a specially crafted options instance into the framework converter that has this custom converter removed, it will resolve the correct JsonClassInfo and includes the required custom value converters but in terms of perf (and usability) it seems far from ideal.

        public override ImmutableArray<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            var optionsDiff = new JsonSerializerOptions(options);
            JsonConverter factory = null;
            foreach (var converter in optionsDiff.Converters)
            {
                if (converter.GetType() == typeof(JsonImmutableArrayConverter))
                    factory = converter;
            }
            if (factory != null)
                optionsDiff.Converters.Remove(factory);

            // We're passing the current options and not the default options because we do want our value converters to work
            // in this case from strings to the enum 'Cases'.
            return _originalConverter.Read(ref reader, typeToConvert, optionsDiff);
        }

If there is some other method by which to achieve what I want, I'd be glad to use it. In any case I think its good to add some documentation on how to call into the framework converters from a custom converter, this has been something I've wanted to do (and done to various degrees of success) multiple times now.

/cc @layomia

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions