Search code examples
c#jsonjson.net

How to deserialize JSON containing an array of objects with a single property name and value each into a c# model?


I have the following model:

public class UserPtr
{
    public int my_var1 { get; set; }
    public int my_var2 { get; set; }
    public int my_var3 { get; set; }
    public int my_var4 { get; set; }
}

And some API response JSON which is:

[ 
    {
        "name": "my_var1",
        "ptr": 1 // "Value_my_var1"
    },
    {
        "name": "my_var2",
        "ptr": 2 // "Value_my_var2"
    },
    {
        "name": "my_var3",
        "ptr": 3 // "Value_my_var3"
    },
    {
        "name": "my_var4",
        "ptr": 4 // "Value_my_var4"
    }
]

I want to set my_var1 = Value_my_var1, my_var2 = Value_my_var2, my_var3 = Value_my_var3

Normally I would use:

JsonConvert.DeserializeObject<UserPtr>(strJson);

But when I do, I get the following exception:

Newtonsoft.Json.JsonSerializationException: Cannot deserialize the current JSON array (e.g. [1,2,3]) into type 'UserPtr' because the type requires a JSON object (e.g. {"name":"value"}) to deserialize correctly. To fix this error either change the JSON to a JSON object (e.g. {"name":"value"}) or change the deserialized type to an array or a type that implements a collection interface (e.g. ICollection, IList) like List that can be deserialized from a JSON array. JsonArrayAttribute can also be added to the type to force it to deserialize from a JSON array.

How can I deserialize this array of objects containing property names and values into my model?


Solution

  • You would like to serialize your model as an array of objects containing property names and property values, where the names and values come from the "default" JSON serialization for your model. You can do this with a custom generic JsonConverter<T> that translates between the default serialization and the array serialization.

    By default, your UserPtr model should be serialized as follows:

    {
      "my_var1": 1,
      "my_var2": 2,
      "my_var3": 2,
      "my_var4": 4
    }
    

    But instead, you are receiving an array of objects containing single name/value pairs as shown in your question, where the names correspond to your model's property names. You would like to bind this array to your model. To accomplish this, you can create a generic converter similar to the one from Deserialize JSON from a Sharepoint 2013 search result into a list of MyClass as follows:

    public class NamePtrPropertyArrayConverter<T> : JsonConverter<T> where T : class, new()
    {
        struct NamePtrDTO
        {
            public string name;
            public object ptr;
        }
        
        public override void WriteJson(JsonWriter writer, T value, JsonSerializer serializer)
        {
            var obj = (JObject)JsonExtensions.DefaultFromObject(serializer, value);
            serializer.Serialize(writer, obj.Properties().Select(p => new NamePtrDTO { name = p.Name, ptr = p.Value }));
        }
    
        public override T ReadJson(JsonReader reader, Type objectType, T existingValue, bool hasExistingValue, JsonSerializer serializer)
        {
            if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
                return null;
            var array = serializer.Deserialize<List<NamePtrDTO>>(reader);
            var obj = new JObject(array.Select(i => new JProperty(i.name, i.ptr)));
            existingValue = existingValue ?? (T)serializer.ContractResolver.ResolveContract(objectType).DefaultCreator();
            using (var subReader = obj.CreateReader())
                serializer.Populate(subReader, existingValue);
            return existingValue;
        }
    }
    
    public static partial class JsonExtensions
    {
        public static JsonReader MoveToContentAndAssert(this JsonReader reader)
        {
            if (reader == null)
                throw new ArgumentNullException();
            if (reader.TokenType == JsonToken.None)       // Skip past beginning of stream.
                reader.ReadAndAssert();
            while (reader.TokenType == JsonToken.Comment) // Skip past comments.
                reader.ReadAndAssert();
            return reader;
        }
    
        public static JsonReader ReadAndAssert(this JsonReader reader)
        {
            if (reader == null)
                throw new ArgumentNullException();
            if (!reader.Read())
                throw new JsonReaderException("Unexpected end of JSON stream.");
            return reader;
        }
    
        // DefaultFromObject() taken from this answer https://stackoverflow.com/a/29720068/3744182
        // By https://stackoverflow.com/users/3744182/dbc
        // To https://stackoverflow.com/questions/29719509/json-net-throws-stackoverflowexception-when-using-jsonconvert
        
        public static JToken DefaultFromObject(this JsonSerializer serializer, object value)
        {
            if (value == null)
                return JValue.CreateNull();
            var dto = Activator.CreateInstance(typeof(DefaultSerializationDTO<>).MakeGenericType(value.GetType()), value);
            var root = JObject.FromObject(dto, serializer);
            return root["Value"].RemoveFromLowestPossibleParent() ?? JValue.CreateNull();
        }
    
        public static JToken RemoveFromLowestPossibleParent(this JToken node)
        {
            if (node == null)
                return null;
            // If the parent is a JProperty, remove that instead of the token itself.
            var contained = node.Parent is JProperty ? node.Parent : node;
            contained.Remove();
            // Also detach the node from its immediate containing property -- Remove() does not do this even though it seems like it should
            if (contained is JProperty)
                ((JProperty)node.Parent).Value = null;
            return node;
        }
    
        interface IHasValue
        {
            object GetValue();
        }
    
        [JsonObject(NamingStrategyType = typeof(DefaultNamingStrategy), IsReference = false)]
        class DefaultSerializationDTO<T> : IHasValue
        {
            public DefaultSerializationDTO(T value) => this.Value = value;
            public DefaultSerializationDTO() { }
            [JsonConverter(typeof(NoConverter)), JsonProperty(ReferenceLoopHandling = ReferenceLoopHandling.Serialize)]
            public T Value { get; set; }
            object IHasValue.GetValue() => Value;
        }       
    }
    
    public class NoConverter : JsonConverter
    {
        // NoConverter taken from this answer https://stackoverflow.com/a/39739105/3744182
        // By https://stackoverflow.com/users/3744182/dbc
        // To https://stackoverflow.com/questions/39738714/selectively-use-default-json-converter
        public override bool CanConvert(Type objectType)  { throw new NotImplementedException(); /* This converter should only be applied via attributes */ }
    
        public override bool CanRead => false;
    
        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) => throw new NotImplementedException();
    
        public override bool CanWrite => false;
    
        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => throw new NotImplementedException();
    }
    

    Then, either deserialize by adding the converter to JsonSerializerSettings.Converters:

    var settings = new JsonSerializerSettings
    {
        Converters = { new NamePtrPropertyArrayConverter<UserPtr>() },
    };
    var model = JsonConvert.DeserializeObject<UserPtr>(strJson, settings);
    

    Or apply the converter directly to your model as follows:

    [JsonConverter(typeof(NamePtrPropertyArrayConverter<UserPtr>))]
    public class UserPtr
    {
        // Contents unchanged
    }
    

    Demo fiddle here.