System.Text.Json: How to apply a JsonConverter for a collection with a custom converter for the collection's items?

Viewed 3037

I have a class that I want to serialize to json. The class contains some trivially serialized members but the problem is that I have a list of objects that are from a cpp library (I have a C# has a wrapper for that library). I can serialize and deserialize the library objects via a custom converter, but I have a list of them and I don't know what to do in this case.
For simplicity I'll use the following example:

class Data
{
    // Some trivial members
    public List<LibraryObject> lst { get; private set; }
}

class LibraryObjectConverter : JsonConverter<LibraryObject>
{
    public override LibraryObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // Implementation logic...
    }

    public override void Write(Utf8JsonWriter writer, LibraryObject value, JsonSerializerOptions options)
    {
        // Implementation logic...
    }
}

If my class contained a known number of those objects I could just add the JsonConverter tag and use the default JsonSerivalizer for my class:

class Data
{
    // Some trivial members
    [JsonConverter(typeof(LibraryObjectConverter ))]
    public List<LibraryObject> lst { get; private set; }
}

Although because the class contains a list of the objects I cannot do that.
Also I know I can use the following code, and It'll work:

var d = new Data();
var serializeOptions = new JsonSerializerOptions();
serializeOptions.Converters.Add(new LibraryObjectkConverter());
serializeOptions.WriteIndented = true;
Console.WriteLine(JsonSerializer.Serialize(d, serializeOptions));

I would like a solution that doesn't require a JsonSerializerOptions, so I can use "The default converter".
I prefer adding tags\logic rather than using the above solution for the ease of use for the consumers of code.

1 Answers

What you are looking for is the equivalent to Newtonsoft's JsonPropertyAttribute.ItemConverterType:

Gets or sets the JsonConverter type used when serializing the property's collection items.

Unfortunately, as of .NET 5 there is no directly equivalent attribute for System.Text.Json. Instead it will be necessary to introduce a JsonConverter decorator that serializes and deserializes collections and arrays using a specified encapsulated converter to serialize and deserialize the items.

The following JsonConverterFactory does exactly this:

public class ItemConverterDecorator<TItemConverter> : JsonConverterFactory where TItemConverter : JsonConverter, new()
{
    readonly TItemConverter itemConverter = new TItemConverter();

    public override bool CanConvert(Type typeToConvert)
    {
        var (itemType, _, _) = GetItemType(typeToConvert);
        if (itemType == null)
            return false;
        return itemConverter.CanConvert(itemType);
    }

    public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
    {
        var (itemType, isArray, isSet) = GetItemType(typeToConvert);
        if (itemType == null)
            return null;
        if (isArray)
            return (JsonConverter)Activator.CreateInstance(typeof(ArrayItemConverterDecorator<>).MakeGenericType(typeof(TItemConverter), itemType), new object [] { options, itemConverter });
        if (!typeToConvert.IsAbstract && !typeToConvert.IsInterface && typeToConvert.GetConstructor(Type.EmptyTypes) != null)
            return (JsonConverter)Activator.CreateInstance(typeof(ConcreteCollectionItemConverterDecorator<,,>).MakeGenericType(typeof(TItemConverter), typeToConvert, typeToConvert, itemType), new object [] { options, itemConverter });
        if (isSet)
        {
            var setType = typeof(HashSet<>).MakeGenericType(itemType);
            if (typeToConvert.IsAssignableFrom(setType))
                return (JsonConverter)Activator.CreateInstance(typeof(ConcreteCollectionItemConverterDecorator<,,>).MakeGenericType(typeof(TItemConverter), setType, typeToConvert, itemType), new object [] { options, itemConverter });
        }
        else
        {
            var listType = typeof(List<>).MakeGenericType(itemType);
            if (typeToConvert.IsAssignableFrom(listType))
                return (JsonConverter)Activator.CreateInstance(typeof(ConcreteCollectionItemConverterDecorator<,,>).MakeGenericType(typeof(TItemConverter), listType, typeToConvert, itemType), new object [] { options, itemConverter });
        }
        // OK it's not an array and we can't find a parameterless constructor for the type.  We can serialize, but not deserialize.
        return (JsonConverter)Activator.CreateInstance(typeof(EnumerableItemConverterDecorator<,>).MakeGenericType(typeof(TItemConverter), typeToConvert, itemType), new object [] { options, itemConverter });
    }
    
    static (Type Type, bool IsArray, bool isSet) GetItemType(Type type)
    {
        // Quick reject for performance
        // Dictionary is not implemented. 
        if (type.IsPrimitive || type == typeof(string) || typeof(IDictionary).IsAssignableFrom(type))
            return (null, false, false);
        if (type.IsArray)
            return type.GetArrayRank() == 1 ? (type.GetElementType(), true, false) : (null, false, false);
        Type itemType = null;
        bool isSet = false;
        foreach (var iType in type.GetInterfacesAndSelf())
        {
            if (iType.IsGenericType)
            {
                var genType = iType.GetGenericTypeDefinition();
                if (genType == typeof(ISet<>))
                {
                    isSet = true;
                }
                else if (genType == typeof(IEnumerable<>))
                {
                    var thisItemType = iType.GetGenericArguments()[0];
                    if (itemType != null && itemType != thisItemType)
                        return (null, false, false); // type implements multiple enumerable types simultaneously. 
                    itemType = thisItemType;
                }
                else if (genType == typeof(IDictionary<,>))
                {
                    return (null, false, false); 
                }
            }
        }
        return (itemType, false, isSet);
    }
    
    abstract class CollectionItemConverterDecoratorBase<TEnumerable, TItem> : JsonConverter<TEnumerable> where TEnumerable : IEnumerable<TItem>
    {
        readonly JsonSerializerOptions modifiedOptions;

        public CollectionItemConverterDecoratorBase(JsonSerializerOptions options, TItemConverter converter)
        {
            // Clone the incoming options and insert the item converter at the beginning of the clone.
            // Then if converter is actually a JsonConverterFactory (e.g. JsonStringEnumConverter) then the correct JsonConverter<T> will be manufactured or fetched.
            this.modifiedOptions = new JsonSerializerOptions(options);
            this.modifiedOptions.Converters.Insert(0, converter);
        }

        protected TCollection BaseRead<TCollection>(ref Utf8JsonReader reader, Type typeToConvert) where TCollection : ICollection<TItem>, new()
        {
            if (reader.TokenType != JsonTokenType.StartArray)
                throw new JsonException(); // Unexpected token type
            var list = new TCollection();
            while (reader.Read())
            {
                if (reader.TokenType == JsonTokenType.EndArray)
                    break;
                list.Add(JsonSerializer.Deserialize<TItem>(ref reader, modifiedOptions));
            }
            return list;
        }

        public sealed override void Write(Utf8JsonWriter writer, TEnumerable value, JsonSerializerOptions options)
        {
            writer.WriteStartArray();
            foreach (var item in value)
                JsonSerializer.Serialize(writer, item, modifiedOptions);
            writer.WriteEndArray();
        }
    }

    sealed class ArrayItemConverterDecorator<TItem> : CollectionItemConverterDecoratorBase<TItem [], TItem>
    {
        public ArrayItemConverterDecorator(JsonSerializerOptions options, TItemConverter converter) : base(options, converter) { }
        public override TItem [] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => BaseRead<List<TItem>>(ref reader, typeToConvert)?.ToArray();
    }

    sealed class ConcreteCollectionItemConverterDecorator<TCollection, TEnumerable, TItem> : CollectionItemConverterDecoratorBase<TEnumerable, TItem> 
        where TCollection : ICollection<TItem>, TEnumerable, new() 
        where TEnumerable : IEnumerable<TItem>
    {
        public ConcreteCollectionItemConverterDecorator(JsonSerializerOptions options, TItemConverter converter) : base(options, converter) { }
        public override TEnumerable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => BaseRead<TCollection>(ref reader, typeToConvert);
    }

    sealed class EnumerableItemConverterDecorator<TEnumerable, TItem> : CollectionItemConverterDecoratorBase<TEnumerable, TItem> where TEnumerable : IEnumerable<TItem>
    {
        public EnumerableItemConverterDecorator(JsonSerializerOptions options, TItemConverter converter) : base(options, converter) { }
        public override TEnumerable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotImplementedException(string.Format("Deserialization is not implemented for type {0}", typeof(TEnumerable)));
    }
}

public static class TypeExtensions
{
    public static IEnumerable<Type> GetInterfacesAndSelf(this Type type) => 
        (type ?? throw new ArgumentNullException()).IsInterface ? new[] { type }.Concat(type.GetInterfaces()) : type.GetInterfaces();
}

And then you would apply it to your model as follows:

class Data
{
    [JsonConverter(typeof(ItemConverterDecorator<LibraryObjectConverter>))]
    public List<LibraryObject> lst { get; set; }
}

Notes:

  • Dictionary values are not supported by ItemConverterDecorator. In contrast Newtonsoft's ItemConverterType property does support dictionary values.

  • The converter does not support deserializing read-only collections.

  • The code makes use of the JsonSerializerOptions copy constructor which was introduced in .NET 5. If you are working in .NET Core 3.x you will need to replace new JsonSerializerOptions(options) with code to construct a fresh JsonSerializerOptions and manually copy each property.

  • In your data model the lst property has a private setter:

    public List<LibraryObject> lst { get; private set; }
    

    Serialization of properties with private setters or getters is not supported in .NET Core 3.x. In .NET 5 it is possible if you mark the property with [JsonInclude]

    [JsonInclude]
    public List<LibraryObject> lst { get; private set; }
    

    See: Non-public property accessors.

Demo fiddle here.

Related