Como lidar com um único item e uma matriz para a mesma propriedade usando JSON.net

101

Estou tentando consertar minha biblioteca SendGridPlus para lidar com eventos SendGrid, mas estou tendo alguns problemas com o tratamento inconsistente de categorias na API.

No exemplo de carga útil a seguir obtido da referência da API SendGrid , você observará que a categorypropriedade de cada item pode ser uma única string ou uma matriz de strings.

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

Parece que minhas opções para fazer JSON.NET assim são consertar a string antes que ela chegue ou configurar JSON.NET para aceitar os dados incorretos. Prefiro não fazer nenhuma análise de string, se puder.

Existe alguma outra maneira de lidar com isso usando Json.Net?

Robert McLaws
fonte

Respostas:

203

A melhor maneira de lidar com essa situação é usar um custom JsonConverter.

Antes de chegarmos ao conversor, precisaremos definir uma classe para desserializar os dados. Para a Categoriespropriedade que pode variar entre um único item e uma matriz, defina-a como um List<string>e marque-a com um [JsonConverter]atributo para que JSON.Net saiba como usar o conversor personalizado para essa propriedade. Eu também recomendaria usar [JsonProperty]atributos para que as propriedades do membro possam receber nomes significativos, independentemente do que está definido no 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; }
}

Aqui está como eu implementaria o conversor. Observe que tornei o conversor genérico para que possa ser usado com strings ou outros tipos de objetos, conforme necessário.

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

Aqui está um pequeno programa que demonstra o conversor em ação com seus dados de amostra:

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

E, finalmente, aqui está o resultado do acima:

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

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

Fiddle: https://dotnetfiddle.net/lERrmu

EDITAR

Se você precisar fazer o contrário, ou seja, serializar, mantendo o mesmo formato, você pode implementar o WriteJson()método do conversor conforme mostrado abaixo. (Certifique-se de remover a CanWritesubstituição ou alterá-la para retornar true, ou então WriteJson()nunca será chamado.)

    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

Brian Rogers
fonte
5
Perfeito! Você é o cara. Felizmente, eu já tinha feito todas as outras coisas sobre como usar JsonProperty para tornar as propriedades mais significativas. Obrigado por uma resposta incrivelmente completa. :)
Robert McLaws,
Sem problemas; que bom que você achou útil.
Brian Rogers
1
Excelente! Isso é o que eu estava procurando. @BrianRogers, se você estiver em Amsterdã, as bebidas são por minha conta!
Mad Dog Tannen,
2
@israelaltar Você não precisa adicionar o conversor à DeserializeObjectchamada se usar o [JsonConverter]atributo na propriedade list em sua classe, conforme mostrado na resposta acima. Se você não usar o atributo, então, sim, você precisará passar o conversor para DeserializeObject.
Brian Rogers
1
@ShaunLangley Para fazer com que o conversor use uma matriz em vez de uma lista, altere todas as referências a List<T>no conversor para T[]e altere .Countpara .Length. dotnetfiddle.net/vnCNgZ
Brian Rogers
6

Eu estava trabalhando nisso há muito tempo e agradeço a Brian por sua resposta. Tudo o que estou adicionando é a resposta do 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

então em sua classe:

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

Espero que isso te economize algum tempo

Grantay
fonte
Erros de digitação: <JsonConverter (GetType (SingleValueArrayConverter (Of YourObject)))> _ Public Property YourLocalName As List (Of YourObject)
GlennG
3

Como uma variação menor da ótima resposta de Brian Rogers , aqui estão duas versões ajustadas de SingleOrArrayConverter<T>.

Em primeiro lugar, aqui está uma versão que funciona para todos, List<T>para cada tipo Tque não seja uma coleção:

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

Ele pode ser usado da seguinte maneira:

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:

  • O conversor evita a necessidade de pré-carregar todo o valor JSON na memória como uma JTokenhierarquia.

  • O conversor não se aplica a listas cujos itens também são serializados como coleções, por exemplo List<string []>

  • O canWriteargumento booleano passado para o construtor controla se as listas de elemento único devem ser re-serializadas como valores JSON ou como matrizes JSON.

  • O conversor ReadJson()usa o existingValueif pré-alocado para oferecer suporte ao preenchimento de membros da lista get-only.

Em segundo lugar, aqui está uma versão que funciona com outras coleções 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();
        }
    }
}

Então, se seu modelo estiver usando, digamos, um ObservableCollection<T>para alguns T, você pode aplicá-lo da seguinte maneira:

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:

  • Além das notas e restrições para SingleOrArrayListConverter, o TCollectiontipo deve ser de leitura / gravação e ter um construtor sem parâmetros.

Demonstração brinca com testes de unidade básicos aqui .

dbc
fonte
0

Tive um problema muito semelhante. Minha solicitação Json era completamente desconhecida para mim. Eu apenas sabia.

Haverá um objectId nele e alguns pares de valores de chave anônimos E matrizes.

Eu usei para um modelo EAV que fiz:

Minha solicitação JSON:

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

Minha classe eu defini:

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

e agora que desejo desserializar atributos desconhecidos com seu valor e matrizes, meu conversor fica assim:

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

Portanto, agora, sempre que obtenho um AnonymObject, posso iterar no Dicionário e, sempre que houver meu sinalizador "ValueDummyForEAV", mudo para a lista, leio a primeira linha e divido os valores. Depois disso, excluo a primeira entrada da lista e prossigo com a iteração do Dicionário.

Talvez alguém tenha o mesmo problema e possa usar isso :)

Atenciosamente Andre

Andre Fritzsche
fonte
0

Você pode usar um JSONConverterAttributecomo encontrado aqui: http://james.newtonking.com/projects/json/help/

Presumindo que você tenha uma classe que parece

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

Você decoraria a propriedade da categoria como visto aqui:

    [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
fonte
Obrigado por isso, mas ainda não resolveu o problema. Quando um array real chega, ainda gera um erro antes que meu código possa ser executado para um objeto que tenha um array real. 'Informações adicionais: Token inesperado ao desserializar o objeto: String. Caminho '[2] .categoria [0]', linha 17, posição 27. '
Robert McLaws,
+ "\" evento \ ": \" processado \ ", \ n" + "} \ n" + "]";
Robert McLaws
Ele processou o primeiro objeto muito bem e não lidou com nenhum array perfeitamente. Mas quando criei um array para o segundo objeto, ele falhou.
Robert McLaws
@AdvancedREI Sem ver seu código imagino que você está deixando o leitor posicionado incorretamente após ler o JSON. Em vez de tentar usar o leitor diretamente, é melhor carregar um objeto JToken do leitor e partir daí. Veja minha resposta para uma implementação funcional do conversor.
Brian Rogers
Detalhes muito melhores na resposta de Brian. Use esse :)
Tim Gabrhel
0

Para lidar com isso, você deve usar um JsonConverter personalizado. Mas provavelmente você já tinha isso em mente. Você está apenas procurando um conversor que possa usar imediatamente. E isso oferece mais do que apenas uma solução para a situação descrita. Dou um exemplo com a pergunta feita.

Como usar meu conversor:

Coloque um Atributo JsonConverter acima da propriedade. 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; }
}

E este é meu conversor:

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

E este conversor usa a seguinte classe:

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

O que isso faz exatamente? Se você colocar o atributo conversor, o conversor será usado para esta propriedade. Você pode usá-lo em um objeto normal se esperar um array json com 1 ou nenhum resultado. Ou use-o em um IEnumerableobjeto onde espera um objeto json ou um array json. (Saiba que um array- object[]- é um IEnumerable) Uma desvantagem é que este conversor só pode ser colocado acima de uma propriedade porque ele pensa que pode converter tudo. E esteja avisado . A stringtambém é um IEnumerable.

E oferece mais do que uma resposta à pergunta: se você pesquisar algo por id, você sabe que obterá um array de volta com um ou nenhum resultado. O ToObjectCollectionSafe<TResult>()método pode cuidar disso para você.

Isso pode ser usado para Resultado Único vs Matriz usando JSON.net e manipula um único item e uma matriz para a mesma propriedade e pode converter uma matriz em um único objeto.

Fiz isso para solicitações REST em um servidor com um filtro que retornou um resultado em uma matriz, mas queria obter o resultado de volta como um único objeto em meu código. E também para uma resposta de resultado OData com resultado expandido com um item em uma matriz.

Divirta-se com isso.

Roberto B
fonte
-2

Eu encontrei outra solução que pode lidar com a categoria como string ou array usando objeto. Assim não preciso atrapalhar o serializador json.

Por favor, dê uma olhada se você tiver tempo e me diga o que você pensa. https://github.com/MarcelloCarreira/sendgrid-csharp-eventwebhook

É baseado na solução em https://sendgrid.com/blog/tracking-email-using-azure-sendgrid-event-webhook-part-1/, mas também adicionei a conversão de data de carimbo de data / hora, atualizei as variáveis ​​para refletir modelo SendGrid atual (e categorias feitas funcionar).

Também criei um manipulador com autenticação básica como opção. Veja os arquivos ashx e os exemplos.

Obrigado!

MarcelloCarreira
fonte