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

101

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

En el siguiente ejemplo de carga útil extraído 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 ingrese o configurar JSON.NET para aceptar los datos incorrectos. Prefiero no hacer ningún análisis de cadenas si puedo salirme con la mía.

¿Hay alguna otra forma en que pueda manejar esto usando Json.Net?

Robert McLaws
fuente

Respuestas:

203

La mejor forma de manejar esta situación es utilizar un archivo 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ínalo como y márquelo List<string>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 las propiedades de los miembros puedan recibir nombres significativos independientemente de lo que esté definido en 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>>();
        }
        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 programa corto 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 que vuelva true, o de lo contrario WriteJson()nunca se llamará).

    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
fuente
5
¡Perfecto! Eres el hombre. Afortunadamente, ya había hecho todas las demás cosas sobre el uso de JsonProperty para que las propiedades fueran más significativas. Gracias por una respuesta increíblemente completa. :)
Robert McLaws
No hay problema; Me alegro de que lo haya encontrado útil.
Brian Rogers
1
¡Excelente! Esto es lo que he estado buscando. @BrianRogers, si alguna vez estás en Amsterdam, ¡las bebidas corren por mi cuenta!
Mad Dog Tannen
2
@israelaltar No necesita agregar el convertidor a la DeserializeObjectllamada si usa el [JsonConverter]atributo en la propiedad de la lista en su clase, como se muestra en la respuesta anterior. Si no usa el atributo, entonces, sí, necesitará pasar el convertidor a DeserializeObject.
Brian Rogers
1
@ShaunLangley Para hacer que el convertidor use una matriz en lugar de una lista, cambie todas las referencias a List<T>en el convertidor ay T[]cambie .Counta .Length. dotnetfiddle.net/vnCNgZ
Brian Rogers
6

Estuve trabajando en esto durante mucho tiempo 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

luego 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
fuente
Errores tipográficos: <JsonConverter (GetType (SingleValueArrayConverter (Of YourObject)))> _ Propiedad pública YourLocalName As List (Of YourObject)
GlennG
3

Como una 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 List<T>para todos los tipos Tque no son en sí mismos una colección:

public class SingleOrArrayListConverter : JsonConverter
{
    // Adapted from this answer https://stackoverflow.com/a/18997172
    // to /programming/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 elementos también se serializan como colecciones, p. Ej. List<string []>

  • El canWriteargumento booleano pasado al constructor controla si volver a serializar listas de un solo elemento como valores JSON o como matrices JSON.

  • El convertidor ReadJson()utiliza el 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 /programming/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 está usando, digamos, an ObservableCollection<T>para algunos 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 de violín con pruebas unitarias básicas aquí .

dbc
fuente
0

Tuve un problema muy similar. Mi solicitud de Json era completamente desconocida para mí. Yo solo lo sabía.

Habrá un objectId en él y algunos pares de valores clave anónimos Y matrices.

Lo usé para un modelo EAV que hice:

Mi solicitud JSON:

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

Mi Clase i definí:

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

y ahora que quiero deserializar atributos desconocidos con su valor y matrices en él, mi convertidor se ve así:

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

Así que ahora, cada vez que obtengo un AnonymObject, puedo recorrer el Diccionario y cada vez que aparece mi Bandera "ValueDummyForEAV", cambio a la lista, leo la primera línea y divido los valores. Después de eso, elimino la primera entrada de la lista y continúo con la iteración del Diccionario.

Tal vez alguien tenga el mismo problema y pueda usar esto :)

Saludos Andre

Andre Fritzsche
fuente
0

Puede usar un JSONConverterAttributetal como se encuentra aquí: http://james.newtonking.com/projects/json/help/

Suponiendo que tienes una clase que se parece a

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

Decoraría la propiedad de la categoría como se ve aquí:

    [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();
  }
}
Tim Gabrhel
fuente
Gracias por esto, pero todavía no soluciona el problema. Cuando entra una matriz real, todavía arroja un error antes de que mi código pueda incluso ejecutarse para un objeto que tiene una matriz real. 'Información adicional: Token inesperado al deserializar el objeto: String. Ruta '[2] .category [0]', línea 17, posición 27. '
Robert McLaws
+ "\" evento \ ": \" procesado \ ", \ n" + "} \ n" + "]";
Robert McLaws
Procesó bien el primer objeto y no manejó ningún arreglo maravillosamente. Pero cuando creé una matriz para el segundo objeto, falló.
Robert McLaws
@AdvancedREI Sin ver su código, supongo que está dejando el lector en una posición incorrecta después de leer el JSON. En lugar de intentar usar el lector directamente, es mejor cargar un objeto JToken desde el lector e ir desde allí. Vea mi respuesta para una implementación funcional del convertidor.
Brian Rogers
Mucho mejor detalle en la respuesta de Brian. Usa ese :)
Tim Gabrhel
0

Para manejar esto, debe usar un JsonConverter personalizado. Pero probablemente ya lo tenías en mente. Solo está buscando un convertidor que pueda usar de inmediato. Y esto ofrece algo más que una solución para la situación descrita. Doy un ejemplo con la pregunta formulada.

Cómo usar mi convertidor:

Coloque un atributo JsonConverter encima de la propiedad. 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; }
}

Y este es mi convertidor:

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

Y este convertidor usa la siguiente clase:

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

¿Qué hace exactamente? Si coloca el atributo del convertidor, el convertidor se utilizará para esta propiedad. Puede usarlo en un objeto normal si espera una matriz json con 1 o sin resultado. O lo usa en un lugar IEnumerabledonde espera un objeto json o una matriz json. (Sepa que un array- object[]- es un IEnumerable) Una desventaja es que este convertidor solo se puede colocar encima de una propiedad porque cree que puede convertir todo. Y ten cuidado . A stringtambién es un IEnumerable.

Y ofrece más que una respuesta a la pregunta: si busca algo por id, sabe que obtendrá una matriz con uno o ningún resultado. El ToObjectCollectionSafe<TResult>()método puede manejar eso por ti.

Esto se puede usar para Single Result vs Array usando JSON.net y maneja un solo elemento y una matriz para la misma propiedad y puede convertir una matriz en un solo objeto.

Hice esto para solicitudes REST en un servidor con un filtro que devolvió un resultado en una matriz, pero quería recuperar el resultado como un solo objeto en mi código. Y también para una respuesta de resultado de OData con resultado expandido con un elemento en una matriz.

Diviértete con eso.

Roberto B
fuente
-2

Encontré otra solución que puede manejar la categoría como cadena o matriz usando object. De esta forma no necesito estropear el serializador json.

Por favor, échale un vistazo si tienes tiempo y dime lo que piensas. https://github.com/MarcelloCarreira/sendgrid-csharp-eventwebhook

Se basa en la solución en https://sendgrid.com/blog/tracking-email-using-azure-sendgrid-event-webhook-part-1/ pero también agregué la conversión de fecha de la marca de tiempo, actualicé las variables para reflejar modelo de SendGrid actual (y las categorías hechas funcionar).

También creé un controlador con autenticación básica como opción. Vea los archivos ashx y los ejemplos.

¡Gracias!

MarcelloCarreira
fuente