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
}
]
}
The 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;
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>()
};
}
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.
You can read more my articles on my blog.