Cómo manejar tanto un solo elemento como una matriz para la misma propiedad usando JSON.net

Resuelto Robert McLaws asked hace 54 años • 9 respuestas

Estoy intentando arreglar mi biblioteca SendGridPlus para manejar los eventos SendGrid, pero tengo algunos problemas con el tratamiento inconsistente de las categorías en la API.

En el siguiente ejemplo de carga útil tomada de la referencia de la API de SendGrid , observará que la categorypropiedad de cada elemento puede ser una sola cadena o una matriz de cadenas.

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

Parece que mis opciones para hacer JSON.NET así son arreglar la cadena antes de que entre o configurar JSON.NET para aceptar datos incorrectos. Preferiría no hacer ningún análisis de cadenas si puedo salirme con la mía.

¿Hay alguna otra manera de manejar esto usando Json.Net?

Robert McLaws avatar Jan 01 '70 08:01 Robert McLaws
Aceptado

La mejor manera de manejar esta situación es utilizar un archivo personalizado JsonConverter.

Antes de llegar al convertidor, necesitaremos definir una clase para deserializar los datos. Para la Categoriespropiedad que puede variar entre un solo elemento y una matriz, defínala como List<string>y márquela con un [JsonConverter]atributo para que JSON.Net sepa usar el convertidor personalizado para esa propiedad. También recomendaría usar [JsonProperty]atributos para que a las propiedades de los miembros se les puedan dar nombres significativos independientemente de lo que esté definido en el 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; }
}

Así es como implementaría el convertidor. Observe que hice el convertidor genérico para que pueda usarse con cadenas u otros tipos de objetos según sea necesario.

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();
    }
}

Aquí hay un breve programa que demuestra el convertidor en acción con sus datos de muestra:

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();
        }
    }
}

Y finalmente, aquí está el resultado de lo anterior:

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

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

Violín: https://dotnetfiddle.net/lERrmu

EDITAR

Si necesita ir al revés, es decir, serializar, manteniendo el mismo formato, puede implementar el WriteJson()método del convertidor como se muestra a continuación. (Asegúrese de eliminar la CanWriteanulación o cambiarla para return true; de lo contrario, WriteJson()nunca lo llamarán).

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

Violín: https://dotnetfiddle.net/XG3eRy

Brian Rogers avatar Sep 25 '2013 05:09 Brian Rogers

Como variación menor de la gran respuesta de Brian Rogers , aquí hay dos versiones modificadas de SingleOrArrayConverter<T>.

En primer lugar, aquí hay una versión que funciona para todos y List<T>para todos los tipos Tque no sean en sí mismos una colección:

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

Se puede utilizar de la siguiente manera:

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);

Notas:

  • El convertidor evita la necesidad de precargar todo el valor JSON en la memoria como una JTokenjerarquía.

  • El convertidor no se aplica a listas cuyos artículos también estén serializados como colecciones, por ejemploList<string []>

  • El argumento booleano canWritepasado al constructor controla si se vuelven a serializar listas de un solo elemento como valores JSON o como matrices JSON.

  • El convertidor ReadJson()utiliza existingValueif preasignado para admitir el llenado de miembros de la lista de solo obtención.

En segundo lugar, aquí hay una versión que funciona con otras colecciones genéricas como 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();
        }
    }
}

Luego, si su modelo usa, digamos, an ObservableCollection<T>for some T, podría aplicarlo de la siguiente manera:

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

Notas:

  • Además de las notas y restricciones para SingleOrArrayListConverter, el TCollectiontipo debe ser de lectura/escritura y tener un constructor sin parámetros.

Demostración con pruebas unitarias básicas aquí .

dbc avatar Dec 14 '2018 00:12 dbc

Estuve trabajando en esto durante años y gracias a Brian por su respuesta. ¡Todo lo que estoy agregando es la respuesta de vb.net!:

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

entonces en tu clase:

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

Espero que esto te ahorre algo de tiempo.

grantay avatar Sep 17 '2015 11:09 grantay

Para aquellos que buscan una solución usando 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);
}

La respuesta se inspiró en la respuesta de Brian Rogers y la respuesta de @dbc desde aquí.

LakShan avatar Mar 20 '2023 09:03 LakShan