151

I'm trying to fix my SendGridPlus library to deal with SendGrid events, but I'm having some trouble with the inconsistent treatment of categories in the API.

In the following example payload taken from the SendGrid API reference, you'll notice that the category property for each item can either be a single string or an array of strings.

[
  {
    "email": "[email protected]",
    "timestamp": 1337966815,
    "category": [
      "newuser",
      "transactional"
    ],
    "event": "open"
  },
  {
    "email": "[email protected]",
    "timestamp": 1337966815,
    "category": "olduser",
    "event": "open"
  }
]

It seems my options to make JSON.NET like this are fixing the string before it comes in, or configuring JSON.NET to accept the incorrect data. I'd rather not do any string parsing if I can get away with it.

Is there any other way I can handle this using Json.Net?

9 Answers 9

287
Answer recommended by Twilio Collective

The best way to handle this situation is to use a custom JsonConverter.

Before we get to the converter, we'll need to define a class to deserialize the data into. For the Categories property that can vary between a single item and an array, define it as a List<string> and mark it with a [JsonConverter] attribute so that JSON.Net will know to use the custom converter for that property. I would also recommend using [JsonProperty] attributes so that the member properties can be given meaningful names independent of what is defined in the JSON.

class Item
{
    [JsonProperty("email")]
    public string Email { get; set; }

    [JsonProperty("timestamp")]
    public int Timestamp { get; set; }

    [JsonProperty("event")]
    public string Event { get; set; }

    [JsonProperty("category")]
    [JsonConverter(typeof(SingleOrArrayConverter<string>))]
    public List<string> Categories { get; set; }
}

Here is how I would implement the converter. Notice I've made the converter generic so that it can be used with strings or other types of objects as needed.

class SingleOrArrayConverter<T> : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return (objectType == typeof(List<T>));
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        JToken token = JToken.Load(reader);
        if (token.Type == JTokenType.Array)
        {
            return token.ToObject<List<T>>();
        }
        if (token.Type == JTokenType.Null)
        {
            return null;
        }
        return new List<T> { token.ToObject<T>() };
    }

    public override bool CanWrite
    {
        get { return false; }
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }
}

Here is an short program demonstrating the converter in action with your sample data:

class Program
{
    static void Main(string[] args)
    {
        string json = @"
        [
          {
            ""email"": ""[email protected]"",
            ""timestamp"": 1337966815,
            ""category"": [
              ""newuser"",
              ""transactional""
            ],
            ""event"": ""open""
          },
          {
            ""email"": ""[email protected]"",
            ""timestamp"": 1337966815,
            ""category"": ""olduser"",
            ""event"": ""open""
          }
        ]";

        List<Item> list = JsonConvert.DeserializeObject<List<Item>>(json);

        foreach (Item obj in list)
        {
            Console.WriteLine("email: " + obj.Email);
            Console.WriteLine("timestamp: " + obj.Timestamp);
            Console.WriteLine("event: " + obj.Event);
            Console.WriteLine("categories: " + string.Join(", ", obj.Categories));
            Console.WriteLine();
        }
    }
}

And finally, here is the output of the above:

email: [email protected]
timestamp: 1337966815
event: open
categories: newuser, transactional

email: [email protected]
timestamp: 1337966815
event: open
categories: olduser

Fiddle: https://dotnetfiddle.net/lERrmu

EDIT

If you need to go the other way, i.e. serialize, while keeping the same format, you can implement the WriteJson() method of the converter as shown below. (Be sure to remove the CanWrite override or change it to return true, or else WriteJson() will never be called.)

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        List<T> list = (List<T>)value;
        if (list.Count == 1)
        {
            value = list[0];
        }
        serializer.Serialize(writer, value);
    }

Fiddle: https://dotnetfiddle.net/XG3eRy

22
  • 6
    Perfect! You're the man. Fortunately, I had already done all of the other stuff about using JsonProperty to make the properties more meaningful. Thank you for an amazingly complete answer. :) Commented Sep 25, 2013 at 14:18
  • No problem; glad you found it helpful. Commented Sep 25, 2013 at 14:50
  • 2
    Excellent! This is what ive been looking for. @BrianRogers, if you are ever in Amsterdam, drinks are on me! Commented Apr 9, 2015 at 13:31
  • 2
    @israelaltar You don't need to add the converter to the DeserializeObject call if you use the [JsonConverter] attribute on the list property in your class, as shown in the answer above. If you don't use the attribute, then, yes, you would need to pass the converter to DeserializeObject. Commented Jul 3, 2017 at 13:44
  • 1
    @ShaunLangley To make the converter use an array instead of a list, change all references to List<T> in the converter to T[] and change .Count to .Length. dotnetfiddle.net/vnCNgZ Commented Oct 7, 2019 at 14:55
11

As a minor variation to the great answer by Brian Rogers, here are two tweaked versions of SingleOrArrayConverter<T>.

Firstly, here is a version that works for all List<T> for every type T that is not itself a collection:

public class SingleOrArrayListConverter : JsonConverter
{
    // Adapted from this answer https://stackoverflow.com/a/18997172
    // to https://stackoverflow.com/questions/18994685/how-to-handle-both-a-single-item-and-an-array-for-the-same-property-using-json-n
    // by Brian Rogers https://stackoverflow.com/users/10263/brian-rogers
    readonly bool canWrite;
    readonly IContractResolver resolver;

    public SingleOrArrayListConverter() : this(false) { }

    public SingleOrArrayListConverter(bool canWrite) : this(canWrite, null) { }

    public SingleOrArrayListConverter(bool canWrite, IContractResolver resolver)
    {
        this.canWrite = canWrite;
        // Use the global default resolver if none is passed in.
        this.resolver = resolver ?? new JsonSerializer().ContractResolver;
    }

    static bool CanConvert(Type objectType, IContractResolver resolver)
    {
        Type itemType;
        JsonArrayContract contract;
        return CanConvert(objectType, resolver, out itemType, out contract);
    }

    static bool CanConvert(Type objectType, IContractResolver resolver, out Type itemType, out JsonArrayContract contract)
    {
        if ((itemType = objectType.GetListItemType()) == null)
        {
            itemType = null;
            contract = null;
            return false;
        }
        // Ensure that [JsonObject] is not applied to the type.
        if ((contract = resolver.ResolveContract(objectType) as JsonArrayContract) == null)
            return false;
        var itemContract = resolver.ResolveContract(itemType);
        // Not implemented for jagged arrays.
        if (itemContract is JsonArrayContract)
            return false;
        return true;
    }

    public override bool CanConvert(Type objectType) { return CanConvert(objectType, resolver); }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        Type itemType;
        JsonArrayContract contract;

        if (!CanConvert(objectType, serializer.ContractResolver, out itemType, out contract))
            throw new JsonSerializationException(string.Format("Invalid type for {0}: {1}", GetType(), objectType));
        if (reader.MoveToContent().TokenType == JsonToken.Null)
            return null;
        var list = (IList)(existingValue ?? contract.DefaultCreator());
        if (reader.TokenType == JsonToken.StartArray)
            serializer.Populate(reader, list);
        else
            // Here we take advantage of the fact that List<T> implements IList to avoid having to use reflection to call the generic Add<T> method.
            list.Add(serializer.Deserialize(reader, itemType));
        return list;
    }

    public override bool CanWrite { get { return canWrite; } }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var list = value as ICollection;
        if (list == null)
            throw new JsonSerializationException(string.Format("Invalid type for {0}: {1}", GetType(), value.GetType()));
        // Here we take advantage of the fact that List<T> implements IList to avoid having to use reflection to call the generic Count method.
        if (list.Count == 1)
        {
            foreach (var item in list)
            {
                serializer.Serialize(writer, item);
                break;
            }
        }
        else
        {
            writer.WriteStartArray();
            foreach (var item in list)
                serializer.Serialize(writer, item);
            writer.WriteEndArray();
        }
    }
}

public static partial class JsonExtensions
{
    public static JsonReader MoveToContent(this JsonReader reader)
    {
        while ((reader.TokenType == JsonToken.Comment || reader.TokenType == JsonToken.None) && reader.Read())
            ;
        return reader;
    }

    internal 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;
    }
}

It can be used as follows:

var settings = new JsonSerializerSettings
{
    // Pass true if you want single-item lists to be reserialized as single items
    Converters = { new SingleOrArrayListConverter(true) },
};
var list = JsonConvert.DeserializeObject<List<Item>>(json, settings);

Notes:

  • The converter avoids the need to pre-load the entire JSON value into memory as a JToken hierarchy.

  • The converter does not apply to lists whose items are also serialized as collections, e.g. List<string []>

  • The Boolean canWrite argument passed to the constructor controls whether to re-serialize single-element lists as JSON values or as JSON arrays.

  • The converter's ReadJson() uses the existingValue if pre-allocated so as to support populating of get-only list members.

Secondly, here is a version that works with other generic collections such as ObservableCollection<T>:

public class SingleOrArrayCollectionConverter<TCollection, TItem> : JsonConverter
    where TCollection : ICollection<TItem>
{
    // Adapted from this answer https://stackoverflow.com/a/18997172
    // to https://stackoverflow.com/questions/18994685/how-to-handle-both-a-single-item-and-an-array-for-the-same-property-using-json-n
    // by Brian Rogers https://stackoverflow.com/users/10263/brian-rogers
    readonly bool canWrite;

    public SingleOrArrayCollectionConverter() : this(false) { }

    public SingleOrArrayCollectionConverter(bool canWrite) { this.canWrite = canWrite; }

    public override bool CanConvert(Type objectType)
    {
        return typeof(TCollection).IsAssignableFrom(objectType);
    }

    static void ValidateItemContract(IContractResolver resolver)
    {
        var itemContract = resolver.ResolveContract(typeof(TItem));
        if (itemContract is JsonArrayContract)
            throw new JsonSerializationException(string.Format("Item contract type {0} not supported.", itemContract));
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        ValidateItemContract(serializer.ContractResolver);
        if (reader.MoveToContent().TokenType == JsonToken.Null)
            return null;
        var list = (ICollection<TItem>)(existingValue ?? serializer.ContractResolver.ResolveContract(objectType).DefaultCreator());
        if (reader.TokenType == JsonToken.StartArray)
            serializer.Populate(reader, list);
        else
            list.Add(serializer.Deserialize<TItem>(reader));
        return list;
    }

    public override bool CanWrite { get { return canWrite; } }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        ValidateItemContract(serializer.ContractResolver);
        var list = value as ICollection<TItem>;
        if (list == null)
            throw new JsonSerializationException(string.Format("Invalid type for {0}: {1}", GetType(), value.GetType()));
        if (list.Count == 1)
        {
            foreach (var item in list)
            {
                serializer.Serialize(writer, item);
                break;
            }
        }
        else
        {
            writer.WriteStartArray();
            foreach (var item in list)
                serializer.Serialize(writer, item);
            writer.WriteEndArray();
        }
    }
}

Then, if your model is using, say, an ObservableCollection<T> for some T, you could apply it as follows:

class Item
{
    public string Email { get; set; }
    public int Timestamp { get; set; }
    public string Event { get; set; }

    [JsonConverter(typeof(SingleOrArrayCollectionConverter<ObservableCollection<string>, string>))]
    public ObservableCollection<string> Category { get; set; }
}

Notes:

  • In addition to the notes and restrictions for SingleOrArrayListConverter, the TCollection type must be read/write and have a parameterless constructor.

Demo fiddle with basic unit tests here.

1
  • 1
    This solution is more useful because it works when you have the situation in different depths of an object. Thanks!
    – GôTô
    Commented Mar 29, 2023 at 14:04
8

I was working on this for ages, and thanks to Brian for his answer. All I am adding is the vb.net answer!:

Public Class SingleValueArrayConverter(Of T)
sometimes-array-and-sometimes-object
    Inherits JsonConverter
    Public Overrides Sub WriteJson(writer As JsonWriter, value As Object, serializer As JsonSerializer)
        Throw New NotImplementedException()
    End Sub

    Public Overrides Function ReadJson(reader As JsonReader, objectType As Type, existingValue As Object, serializer As JsonSerializer) As Object
        Dim retVal As Object = New [Object]()
        If reader.TokenType = JsonToken.StartObject Then
            Dim instance As T = DirectCast(serializer.Deserialize(reader, GetType(T)), T)
            retVal = New List(Of T)() From { _
                instance _
            }
        ElseIf reader.TokenType = JsonToken.StartArray Then
            retVal = serializer.Deserialize(reader, objectType)
        End If
        Return retVal
    End Function
    Public Overrides Function CanConvert(objectType As Type) As Boolean
        Return False
    End Function
End Class

then in your class:

 <JsonProperty(PropertyName:="JsonName)> _
 <JsonConverter(GetType(SingleValueArrayConverter(Of YourObject)))> _
    Public Property YourLocalName As List(Of YourObject)

Hope this saves you some time

1
  • Typos: <JsonConverter(GetType(SingleValueArrayConverter(Of YourObject)))> _ Public Property YourLocalName As List(Of YourObject)
    – GlennG
    Commented Mar 11, 2016 at 15:33
3

For those who are looking for a solution using System.Text.Json

public class SingleOrArrayConverter : JsonConverter<List<string>>
{
    public override List<string> Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.Null:
                return null;
            case JsonTokenType.StartArray:
                var list = new List<string>();
                while (reader.Read())
                {
                    if (reader.TokenType == JsonTokenType.EndArray)
                        break;
                    list.Add(JsonSerializer.Deserialize<string>(ref reader, options));
                }
                return list;
            default:
                return new List<string> { JsonSerializer.Deserialize<string>(ref reader, options) };
        }
    }

    public override void Write(
        Utf8JsonWriter writer,
        List<string> objectToWrite,
        JsonSerializerOptions options) =>
        JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options);
}

Answer was inspired by Brian Rogers's answer and @dbc's answer from here

1

To handle this you have to use a custom JsonConverter. But you probably already had that in mind. You are just looking for a converter that you can use immediately. And this offers more than just a solution for the situation described. I give an example with the question asked.

How to use my converter:

Place a JsonConverter Attribute above the property. JsonConverter(typeof(SafeCollectionConverter))

public class SendGridEvent
{
    [JsonProperty("email")]
    public string Email { get; set; }

    [JsonProperty("timestamp")]
    public long Timestamp { get; set; }

    [JsonProperty("category"), JsonConverter(typeof(SafeCollectionConverter))]
    public string[] Category { get; set; }

    [JsonProperty("event")]
    public string Event { get; set; }
}

And this is my converter:

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;

namespace stackoverflow.question18994685
{
    public class SafeCollectionConverter : JsonConverter
    {
        public override bool CanConvert(Type objectType)
        {
            return true;
        }

        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            //This not works for Populate (on existingValue)
            return serializer.Deserialize<JToken>(reader).ToObjectCollectionSafe(objectType, serializer);
        }     

        public override bool CanWrite => false;

        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            throw new NotImplementedException();
        }
    }
}

And this converter uses the following class:

using System;

namespace Newtonsoft.Json.Linq
{
    public static class SafeJsonConvertExtensions
    {
        public static object ToObjectCollectionSafe(this JToken jToken, Type objectType)
        {
            return ToObjectCollectionSafe(jToken, objectType, JsonSerializer.CreateDefault());
        }

        public static object ToObjectCollectionSafe(this JToken jToken, Type objectType, JsonSerializer jsonSerializer)
        {
            var expectArray = typeof(System.Collections.IEnumerable).IsAssignableFrom(objectType);

            if (jToken is JArray jArray)
            {
                if (!expectArray)
                {
                    //to object via singel
                    if (jArray.Count == 0)
                        return JValue.CreateNull().ToObject(objectType, jsonSerializer);

                    if (jArray.Count == 1)
                        return jArray.First.ToObject(objectType, jsonSerializer);
                }
            }
            else if (expectArray)
            {
                //to object via JArray
                return new JArray(jToken).ToObject(objectType, jsonSerializer);
            }

            return jToken.ToObject(objectType, jsonSerializer);
        }

        public static T ToObjectCollectionSafe<T>(this JToken jToken)
        {
            return (T)ToObjectCollectionSafe(jToken, typeof(T));
        }

        public static T ToObjectCollectionSafe<T>(this JToken jToken, JsonSerializer jsonSerializer)
        {
            return (T)ToObjectCollectionSafe(jToken, typeof(T), jsonSerializer);
        }
    }
}

What does it do exactly? If you place the converter attribute the converter will be used for this property. You can use it on a normal object if you expect a json array with 1 or no result. Or you use it on an IEnumerable where you expect a json object or json array. (Know that an array -object[]- is an IEnumerable) A disadvantage is that this converter can only be placed above a property because he thinks he can convert everything. And be warned. A string is also an IEnumerable.

And it offers more than an answer to the question: If you search for something by id you know that you will get an array back with one or no result. The ToObjectCollectionSafe<TResult>() method can handle that for you.

This is usable for Single Result vs Array using JSON.net and handle both a single item and an array for the same property and can convert an array to a single object.

I made this for REST requests on a server with a filter that returned one result in an array but wanted to get the result back as a single object in my code. And also for a OData result response with expanded result with one item in an array.

Have fun with it.

1

Just wanted to add to @dbc excellent response above on the SingleOrArrayCollectionConverter. I was able to modify it to use with a stream from an HTTP client. Here is a snippet (you will have to set up the requestUrl (string) and the httpClient (using System.Net.Http;).

public async Task<IList<T>> HttpRequest<T>(HttpClient httpClient, string requestedUrl, CancellationToken cancellationToken)
    {
       using (var request = new HttpRequestMessage(HttpMethod.Get, requestedUrl))
       using (var httpResponseMessage = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
       {
          if (httpResponseMessage.IsSuccessStatusCode)
          {
             using var stream = await httpResponseMessage.Content.ReadAsStreamAsync();    
             using var streamReader = new StreamReader(stream);
             using var jsonTextReader = new JsonTextReader(streamReader );
             var settings = new JsonSerializerSettings
             {
                // Pass true if you want single-item lists to be reserialized as single items
                Converters = { new SingleOrArrayCollectionConverter(true) },
             };
             var jsonSerializer = JsonSerializer.Create(settings);
             return jsonSerializer.Deserialize<List<T>>(jsonTextReader);
     }

I apologize if there are missing brackets or misspellings, it was not easy to paste code in here.

0

I had a very similar Problem. My Json Request was completly unknown for me. I only knew.

There will be an objectId in it and some anonym key value pairs AND arrays.

I used it for an EAV Model i did:

My JSON Request:

{objectId": 2, "firstName": "Hans", "email" :[ "[email protected]","[email protected]"], "name": "Andre", "something" :["232","123"] }

My Class i defined:

[JsonConverter(typeof(AnonyObjectConverter))]
public class AnonymObject
{
    public AnonymObject()
    {
        fields = new Dictionary<string, string>();
        list = new List<string>();
    }

    public string objectid { get; set; }
    public Dictionary<string, string> fields { get; set; }
    public List<string> list { get; set; }
}

and now that i want to deserialize unknown attributes with its value and arrays in it my Converter looks like that:

   public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        AnonymObject anonym = existingValue as AnonymObject ?? new AnonymObject();
        bool isList = false;
        StringBuilder listValues = new StringBuilder();

        while (reader.Read())
        {
            if (reader.TokenType == JsonToken.EndObject) continue;

            if (isList)
            {
                while (reader.TokenType != JsonToken.EndArray)
                {
                    listValues.Append(reader.Value.ToString() + ", ");

                    reader.Read();
                }
                anonym.list.Add(listValues.ToString());
                isList = false;

                continue;
            }

            var value = reader.Value.ToString();

            switch (value.ToLower())
            {
                case "objectid":
                    anonym.objectid = reader.ReadAsString();
                    break;
                default:
                    string val;

                    reader.Read();
                    if(reader.TokenType == JsonToken.StartArray)
                    {
                        isList = true;
                        val = "ValueDummyForEAV";
                    }
                    else
                    {
                        val = reader.Value.ToString();
                    }
                    try
                    {
                        anonym.fields.Add(value, val);
                    }
                    catch(ArgumentException e)
                    {
                        throw new ArgumentException("Multiple Attribute found");
                    }
                    break;
            }

        }

        return anonym;
    }

So now everytime i get an AnonymObject i can iterate through the Dictionary and everytime there is my Flag "ValueDummyForEAV" i switch to the list, read the first line and split the values. After that i delete the first entry from the list and go on with iteration from the Dictionary.

Maybe someone has the same problem and can use this :)

Regards Andre

0

You can use a JSONConverterAttribute as found here: http://james.newtonking.com/projects/json/help/

Presuming you have a class that looks like

public class RootObject
{
    public string email { get; set; }
    public int timestamp { get; set; }
    public string smtpid { get; set; }
    public string @event { get; set; }
    public string category[] { get; set; }
}

You'd decorate the category property as seen here:

    [JsonConverter(typeof(SendGridCategoryConverter))]
    public string category { get; set; }

public class SendGridCategoryConverter : JsonConverter
{
  public override bool CanConvert(Type objectType)
  {
    return true; // add your own logic
  }

  public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
  {
   // do work here to handle returning the array regardless of the number of objects in 
  }

  public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
  {
    // Left as an exercise to the reader :)
    throw new NotImplementedException();
  }
}
5
  • Thanks for this, but it still doesn't fix the problem. When an actual array comes in, it still throws an error before my code can even execute for an object that has an actual array. 'Additional information: Unexpected token when deserializing object: String. Path '[2].category[0]', line 17, position 27.' Commented Sep 25, 2013 at 3:32
  • private string payload = "[\n" + "{\n" + "\"email\": \"[email protected]\",\n" + "\"timestamp\": 1337966815,\n" + "\"smtp-id\": \"<[email protected]>\",\n" + "\"category\": \"newuser\",\n" + "\"event\": \"clicked\"\n" + "}, " + "{"+ "\"email\": \"[email protected]\",\n" + "\"timestamp\": 1337969592,\n" + "\"smtp-id\": \"<[email protected]>\",\n" + "\"category\": [\"somestring1\",\"somestring2\"],\n" + "\"event\": \"processed\",\n" + "}\n" + "]"; Commented Sep 25, 2013 at 3:34
  • It processed the first object fine and dealt with no array beautifully. But when I created an array for the 2nd object, it failed. Commented Sep 25, 2013 at 3:35
  • @AdvancedREI Without seeing your code I would guess that you are leaving the reader incorrectly positioned after you read the JSON. Instead of trying to use the reader directly, it is better to load a JToken object from the reader and go from there. See my answer for a working implementation of the converter. Commented Sep 25, 2013 at 12:05
  • Much better detail in Brian's answer. Use that one :) Commented Sep 25, 2013 at 13:26
0

You don't need any custom json converters.

you can create list of items using a simple helper

List<Item> items = JArray.Parse(json).Select(i => GetItem(i)).ToList();

public Item GetItem(JToken item)
{
    if (((JObject)item)["category"].Type != JTokenType.Array)
        item["category"] = new JArray(item["category"]);

    return item.ToObject<Item>();
}

or you can create a very simple JsonConstructor

List<Item> items = JsonConvert.DeserializeObject<List<Item>>(json);

public partial class Item
{
    // ... all another properties

   public List<string> Category { get; set; }

    [JsonConstructor]
    public Item(JToken category)
    {
        if (category.GetType().Name == "JArray")
            Category = category.ToObject<List<string>>();
        else
            Category = new List<string> { category.ToString() };
    }
    public Item() { }
}

or you can use a json converter

using Newtonsoft.Json;

public partial class Item
{
    // ... all another properties
    [Newtonsoft.Json.JsonConverter(typeof(StringToListConverter))]
    [JsonPropertyName("category")]
    public List<string> Category { get; set; }
}

public class StringToListConverter : Newtonsoft.Json.JsonConverter<List<string>>
{
    public override List<string> ReadJson(JsonReader reader, Type objectType, List<string> existingValue, bool hasExistingValue, Newtonsoft.Json.JsonSerializer serializer)
    {
        var jt = JToken.Load(reader);
        return jt.Type == JTokenType.Array 
                ? jt.ToObject<List<string>>()
                : new List<string> { (string)jt };
    }
    public override void WriteJson(JsonWriter writer, List<string> value, Newtonsoft.Json.JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }
    public override bool CanWrite
    {
        get { return false; }
    }
}

and since I could not find any solution if System.Text.Json is used

using System.Text.Json;

List<Item> items = System.Text.Json.JsonSerializer.Deserialize<List<Item>>(json);

public partial class Item
{
    // ... all another properties
    [System.Text.Json.Serialization.JsonConverter(typeof(StringToListConverter))]
    [JsonPropertyName("category")]
    public List<string> Category { get; set; }
}


public class StringToListConverter : System.Text.Json.Serialization.JsonConverter<List<string>>
{
    public override List<string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        using var jsonDoc = JsonDocument.ParseValue(ref reader);

        var element = jsonDoc.RootElement;
        return element.ValueKind == JsonValueKind.String
            ? new List<string> { element.GetString() }
            : System.Text.Json.JsonSerializer.Deserialize<List<string>>(element.GetRawText());
    }
    public override void Write(Utf8JsonWriter writer, List<string> value, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }
}

Not the answer you're looking for? Browse other questions tagged or ask your own question.