Search code examples
c#jsonjson.net

Can I set my own default List<T> converter in JSON.net (without attributes)


Service which I work with uses strange serialization. When array is empty it looks like this:

"SomeArr":[]

But when 'SomeArr' has items it looks like this:

"SomeArr":
{
    "item1": { "prop1":"value1" },
    "item2": { "prop1":"value1" }
    ...
}
    

So it's not even array now but JObject with properties instead of array enumerators
I have this converter that must be applied to all properties with List type

public class ArrayObjectConverter<T> : JsonConverter<List<T>>
{
    public override void WriteJson(JsonWriter writer, List<T>? value, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }

    public override List<T>? ReadJson(JsonReader reader, Type objectType, List<T>? existingValue, bool hasExistingValue, JsonSerializer serializer)
    {
        List<T> result = new();

        if (reader.TokenType == JsonToken.StartArray)
        {
            var jArray = JArray.Load(reader);
            //'LINQ Select' because sometimes arrays are normal
            //So if I set this converter as default we select objects from this array
            return jArray.Select(jt => jt.ToObject<T>()!).ToList(); 
        }
        else
        {
            var jObject = JObject.Load(reader);
            foreach (var kvp in jObject)
            {
                var obj = kvp.Value!.ToObject<T>()!;
                result.Add(obj);
            }
            return result;
        }
    }
}

So how I can set this converter as default (e.g. in serializer.settings). The problem is this converter is generic type and I can't set in settings without generic argument.
Of course I can put [JsonConverter(typeof(ArrayObjectConverter<T>))] attribute for every collection. But my json classes already have a lot of boilerplate. Any suggestions?
P.S. The solution should be as optimized as possible because the speed of deserialization is very important.


Solution

  • You can take advantage of the fact that List<T> implements the non-generic interface IList to create a non-generic JsonConverter for all List<T> types:

    public class ArrayObjectConverter : JsonConverter
    {
        public override bool CanConvert(Type t) => t.GetListItemType() != null;
        public override bool CanWrite => false;
    
        public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) => throw new NotImplementedException();
    
        public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
        {
            System.Diagnostics.Debug.Assert(objectType.GetListItemType() != null);
    
            if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
                return null;
        
            IList value = existingValue as IList ?? (IList)serializer.ContractResolver.ResolveContract(objectType).DefaultCreator!();
    
            if (reader.TokenType == JsonToken.StartArray)
            {
                serializer.Populate(reader, value);
            }
            else if (reader.TokenType == JsonToken.StartObject)
            {
                var itemType = objectType.GetListItemType().ThrowOnNull();
                while (reader.ReadToContentAndAssert().TokenType != JsonToken.EndObject)
                {
                    // Eat the property name
                    reader.AssertTokenType(JsonToken.PropertyName).ReadToContentAndAssert();
                    // Deserialize the property value and add it to the list.
                    value.Add(serializer.Deserialize(reader, itemType));
                }
            }
            else
            {
                throw new JsonSerializationException(string.Format("Unknown token type {0}", reader.TokenType));
            }
            
            return value;
        }
    }
    
    public static partial class JsonExtensions
    {
        public static JsonReader AssertTokenType(this JsonReader reader, JsonToken tokenType) => 
            reader.TokenType == tokenType ? reader : throw new JsonSerializationException(string.Format("Unexpected token {0}, expected {1}", reader.TokenType, tokenType));
        
        public static JsonReader ReadToContentAndAssert(this JsonReader reader) =>
            reader.ReadAndAssert().MoveToContentAndAssert();
    
        public static JsonReader MoveToContentAndAssert(this JsonReader reader)
        {
            if (reader == null)
                throw new ArgumentNullException();
            if (reader.TokenType == JsonToken.None)       // Skip past beginning of stream.
                reader.ReadAndAssert();
            while (reader.TokenType == JsonToken.Comment) // Skip past comments.
                reader.ReadAndAssert();
            return reader;
        }
    
        public static JsonReader ReadAndAssert(this JsonReader reader)
        {
            if (reader == null)
                throw new ArgumentNullException();
            if (!reader.Read())
                throw new JsonReaderException("Unexpected end of JSON stream.");
            return reader;
        }
        
        public static Type? GetListItemType(this Type type)
        {
            // Quick reject for performance
            if (type.IsPrimitive || type.IsArray || type == typeof(string))
                return null;
            while (type != null)
            {
                if (type.IsGenericType)
                {
                    var genType = type.GetGenericTypeDefinition();
                    if (genType == typeof(List<>))
                        return type.GetGenericArguments()[0];
                }
                type = type.BaseType!;
            }
            return null;
        }
    }
    
    public static partial class ObjectExtensions
    {
        public static T ThrowOnNull<T>(this T? value) where T : class => value ?? throw new ArgumentNullException(nameof(value));
    }
    

    Notes:

    • Your question mentions The solution should be as optimized as possible so the converter deserializes directly from the JsonReader without needing to pre-load anything into intermediate JArray or JObject instances.

    • The converter should work for subclasses of List<T> as well.

    • If you need to support types that implement ICollection<T> types but do not also implement the non-generic IList interface (such as HashSet<T>), you will need to use reflection to invoke a generic method from the non-generic ReadJson() e.g. as shown in this answer to Newtonsoft Json Deserialize Dictionary as Key/Value list from DataContractJsonSerializer.

    Demo fiddle here.