Friday, September 15, 2017

Complex deserialization of objects from JSON

Sometimes we need to deserialize JSON into an object model. Here, I'll explain deserialization of objects belonging to a class hierarchy with a support of different formats using Newtonsoft Json.Net library.

Consider the following simple example. I'd like to deserialize from JSON objects of this type:

public class DataModel
{
    [JsonProperty]
    public ValueModel[] Values { get; set; }
}

The only thing is that ValueModel is an abstract class, defining this hierarchy:

public enum ValueType
{
    String,
    Integer
}

public abstract class ValueModel
{
    public abstract ValueType Type { get; }

    [JsonProperty]
    public string Id { get; set; }
}

public class StringValueModel : ValueModel
{
    [JsonProperty]
    public string Value { get; set; }

    public override ValueType Type => ValueType.String;
}

public class IntValueModel : ValueModel
{
    [JsonProperty]
    public int Value { get; set; }

    public override ValueType Type => ValueType.Integer;
}

I'd like to deserialize JSON like this:

{
    values: [
        {
            id: 'text',
            value: 'some comment'
        },
        {
            id: 'number',
            value: 4
        }
    ]
}

I want the result of this deserialization to have two objects in the Values array: the first of the StringValueModel type and the second of the IntValueModel type. How to achieve it?

First of all, we need to add into JSON discriminator field. Let's call it 'type':

{
    values: [
        {
            type: 'string',
            id: 'text',
            value: 'some comment'
        },
        {
            type: 'integer',
            id: 'number',
            value: 4
        }
    ]
}

Next step is to create custom JSON converter. It is a class, which inherits from JsonConverter class from Json.Net library:

public class ValueModelJsonConverter : JsonConverter
{
    public override bool CanWrite => false;

    public override bool CanConvert(Type objectType)
    {
        return typeof(ValueModel).IsAssignableFrom(objectType);
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        throw new NotSupportedException("Custom converter should only be used while deserializing.");
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
        JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null)
            return null;

        // Load JObject from stream
        JObject jObject = JObject.Load(reader);
        if (jObject == null)
            return null;

        ValueType valueType;
        if (Enum.TryParse(jObject.Value<string>("type"), true, out valueType))
        {
            switch (valueType)
            {
                case ValueType.String:
                    var stringValueModel = new StringValueModel();
                    serializer.Populate(jObject.CreateReader(), stringValueModel);
                    return stringValueModel;
                case ValueType.Integer:
                    var intValueModel = new IntValueModel();
                    serializer.Populate(jObject.CreateReader(), intValueModel);
                    return intValueModel;
                default:
                    throw new ArgumentException($"Unknown value type '{valueType}'");
            }
        }

        throw new ArgumentException($"Unable to parse value object");
    }
}

In the ReadJson method of this class, we create JObject representing our value model from the instance of JsonReader. Then we analyze the value of the 'type' property to decide, which concrete object should be created (StringValueModel or IntValueModel). And finally, we populate properties of our created objects using serializer.Populate.

To use our converter, we should add it to JsonSerializerSettings:

public class JsonParser
{
    private readonly JsonSerializerSettings _jsonSerializerSettings;

    public JsonParser()
    {
        _jsonSerializerSettings = new JsonSerializerSettings
        {
            Converters =
            {
                new StringEnumConverter {CamelCaseText = false},
                new ValueModelJsonConverter()
            },
            ContractResolver = new CamelCasePropertyNamesContractResolver()
        };
    }

    public DataModel Parse(string expression)
    {
        return JsonConvert.DeserializeObject<DataModel>(expression, _jsonSerializerSettings);
    }
}

Here, we can use JsonParser to convert JSON strings into DataModel:

var json = @"
{
    values: [
        {
            type: 'string',
            id: 'text',
            value: 'some comment'
        },
        {
            type: 'integer',
            id: 'number',
            value: 4
        }
    ]
}";
var parser = new JsonParser();

DataModel data = parser.Parse(json);

Now let me change requirements a little bit. Let's say, that Id property of ValueModel objects is not required. For example, if a user has not specified it, we'll generate it automatically somehow. In this case, it is sensible to allow simplified syntax of JSON:

{
    values: [
        'another text',
        3,
        {
            type: 'string',
            id: 'text',
            value: 'some comment'
        },
        {
            type: 'integer',
            id: 'number',
            value: 4
        }
    ]
}

If in 'values' array we see a string, we should create an instance of StringValueModel. If we see an integer, we should create IntValueModel.

How can we do it? It requires a small change in the ReadJson method of our ValueModelJsonConverter class:

public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
    JsonSerializer serializer)
{
    if (reader.TokenType == JsonToken.Null)
        return null;

    if (reader.TokenType == JsonToken.String)
    {
        return new StringValueModel
        {
            Value = JToken.Load(reader).Value<string>()
        };
    }

    if (reader.TokenType == JsonToken.Integer)
    {
        return new IntValueModel
        {
            Value = JToken.Load(reader).Value<int>()
        };
    }

    // Load JObject from stream
    JObject jObject = JObject.Load(reader);
    if (jObject == null)
        return null;

    ValueType valueType;
    if (Enum.TryParse(jObject.Value<string>("type"), true, out valueType))
    {
        switch (valueType)
        {
            case ValueType.String:
                var stringValueModel = new StringValueModel();
                serializer.Populate(jObject.CreateReader(), stringValueModel);
                return stringValueModel;
            case ValueType.Integer:
                var intValueModel = new IntValueModel();
                serializer.Populate(jObject.CreateReader(), intValueModel);
                return intValueModel;
            default:
                throw new ArgumentException($"Unknown value type '{valueType}'");
        }
    }

    throw new ArgumentException($"Unable to parse value object");
}

Here we analyze reader.TokenType property to understand if we have simple string or integer. Then we read this value using Value<T> method.

No comments:

Post a Comment