Cómo manejar tanto un solo elemento como una matriz para la misma propiedad usando JSON.net
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 category
propiedad 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?
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 Categories
propiedad 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 CanWrite
anulació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
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 T
que 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
JToken
jerarquía.El convertidor no se aplica a listas cuyos artículos también estén serializados como colecciones, por ejemplo
List<string []>
El argumento booleano
canWrite
pasado al constructor controla si se vuelven a serializar listas de un solo elemento como valores JSON o como matrices JSON.El convertidor
ReadJson()
utilizaexistingValue
if 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
, elTCollection
tipo debe ser de lectura/escritura y tener un constructor sin parámetros.
Demostración con pruebas unitarias básicas aquí .
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.
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í.