Deserialize hal+json to complex model
The most likely solution is as suggested that you create a custom converter to parse the desired models.
In this case the custom converter would need to be able to read nested paths.
This should provide a simple workaround.
public class NestedJsonPathConverter : JsonConverter {
public override object ReadJson(JsonReader reader, Type objectType,
object existingValue, JsonSerializer serializer) {
JObject jo = JObject.Load(reader);
var properties = jo.Properties();
object targetObj = existingValue ?? Activator.CreateInstance(objectType);
var resolver = serializer.ContractResolver as DefaultContractResolver;
foreach (PropertyInfo propertyInfo in objectType.GetProperties()
.Where(p => p.CanRead && p.CanWrite)) {
var attributes = propertyInfo.GetCustomAttributes(true).ToArray();
if (attributes.OfType<JsonIgnoreAttribute>().Any())
continue;
var jsonProperty = attributes.OfType<JsonPropertyAttribute>().FirstOrDefault();
var jsonPath = (jsonProperty != null ? jsonProperty.PropertyName : propertyInfo.Name);
if (resolver != null) {
jsonPath = resolver.GetResolvedPropertyName(jsonPath);
}
JToken token = jo.SelectToken(jsonPath) ?? GetTokenCaseInsensitive(properties, jsonPath);
if (token != null && token.Type != JTokenType.Null) {
object value = token.ToObject(propertyInfo.PropertyType, serializer);
propertyInfo.SetValue(targetObj, value, null);
}
}
return targetObj;
}
JToken GetTokenCaseInsensitive(IEnumerable<JProperty> properties, string jsonPath) {
var parts = jsonPath.Split('.');
var property = properties.FirstOrDefault(p =>
string.Equals(p.Name, parts[0], StringComparison.OrdinalIgnoreCase)
);
for (var i = 1; i < parts.Length && property != null && property.Value is JObject; i++) {
var jo = property.Value as JObject;
property = jo.Properties().FirstOrDefault(p =>
string.Equals(p.Name, parts[i], StringComparison.OrdinalIgnoreCase)
);
}
if (property != null && property.Type != JTokenType.Null) {
return property.Value;
}
return null;
}
public override bool CanConvert(Type objectType) {
//Check if any JsonPropertyAttribute has a nested property name {name}.{sub}
return objectType
.GetProperties()
.Any(p =>
p.CanRead
&& p.CanWrite
&& p.GetCustomAttributes(true)
.OfType<JsonPropertyAttribute>()
.Any(jp => (jp.PropertyName ?? p.Name).Contains('.'))
);
}
public override bool CanWrite {
get { return false; }
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) {
throw new NotImplementedException();
}
}
The original class structure now does not need to change, with only the properties that require custom paths needing to be decorated with JsonPropertyAttribute
that indicates the path to populate the property.
In this example
public class Customer {
public Guid Id { get; set; }
public string Country { get; set; }
[JsonProperty("_embedded.company")]
public LegalPerson Company { get; set; }
}
public class LegalPerson {
public string Name { get; set; }
public string IndustrySector { get; set; }
public string Owner { get; set; }
[JsonProperty("_embedded.emailAddresses")]
public ContactInfo[] EmailAddresses { get; set; }
[JsonProperty("_embedded.phoneNumbers")]
public ContactInfo[] PhoneNumbers { get; set; }
}
Just include the converter as needed.
var settings = new JsonSerializerSettings {
ContractResolver = new DefaultContractResolver {
NamingStrategy = new CamelCaseNamingStrategy()
}
};
settings.Converters.Add(new NestedJsonPathConverter());
var customer = JsonConvert.DeserializeObject<Customer>(json, settings);
The two important parts of the code are the GetTokenCaseInsensitive
method that searches for the requested token and allows for nested paths that can be case-insensitive.
JToken GetTokenCaseInsensitive(IEnumerable<JProperty> properties, string jsonPath) {
var parts = jsonPath.Split('.');
var property = properties.FirstOrDefault(p =>
string.Equals(p.Name, parts[0], StringComparison.OrdinalIgnoreCase)
);
for (var i = 1; i < parts.Length && property != null && property.Value is JObject; i++) {
var jo = property.Value as JObject;
property = jo.Properties().FirstOrDefault(p =>
string.Equals(p.Name, parts[i], StringComparison.OrdinalIgnoreCase)
);
}
if (property != null && property.Type != JTokenType.Null) {
return property.Value;
}
return null;
}
and the overridden CanConvert
which will check of any properties have nested paths
public override bool CanConvert(Type objectType) {
//Check if any JsonPropertyAttribute has a nested property name {name}.{sub}
return objectType
.GetProperties()
.Any(p =>
p.CanRead
&& p.CanWrite
&& p.GetCustomAttributes(true)
.OfType<JsonPropertyAttribute>()
.Any(jp => (jp.PropertyName ?? p.Name).Contains('.'))
);
}
Possible solution is to use custom JsonConverter but not implement all converting logic from scratch.
Some time ago I found and updated JsonPathConverter which allows to use property path for JsonProperty attribute. For example in your case
[JsonProperty("_embedded.company")]
public LegalPerson Company { get; set; }
So your models with attributes will look like:
[JsonConverter(typeof(JsonPathConverter))]
public class Customer
{
[JsonProperty("id")]
public Guid Id { get; set; }
[JsonProperty("country")]
public string Country { get; set; }
[JsonProperty("_embedded.company")]
public LegalPerson Company { get; set; }
}
[JsonConverter(typeof(JsonPathConverter))]
public class LegalPerson
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("industrySector")]
public string IndustrySector { get; set; }
[JsonProperty("owner")]
public string Owner { get; set; }
[JsonProperty("_embedded.emailAddresses")]
public ContactInfo[] EmailAddresses { get; set; }
[JsonProperty("_embedded.phoneNumbers")]
public ContactInfo[] PhoneNumbers { get; set; }
}
public class ContactInfo
{
[JsonProperty("id")]
public Guid Id { get; set; }
[JsonProperty("value")]
public string Type { get; set; }
[JsonProperty("type")]
public string Value { get; set; }
}
The code of JsonPathConverter is this. But I believe you can improve it.
public class JsonPathConverter : JsonConverter
{
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var properties = value.GetType().GetRuntimeProperties().Where(p => p.CanRead && p.CanWrite);
JObject main = new JObject();
foreach (PropertyInfo prop in properties)
{
JsonPropertyAttribute att = prop.GetCustomAttributes(true)
.OfType<JsonPropertyAttribute>()
.FirstOrDefault();
string jsonPath = att != null ? att.PropertyName : prop.Name;
if (serializer.ContractResolver is DefaultContractResolver resolver)
jsonPath = resolver.GetResolvedPropertyName(jsonPath);
var nesting = jsonPath.Split('.');
JObject lastLevel = main;
for (int i = 0; i < nesting.Length; ++i)
{
if (i == (nesting.Length - 1))
{
lastLevel[nesting[i]] = new JValue(prop.GetValue(value));
}
else
{
if (lastLevel[nesting[i]] == null)
lastLevel[nesting[i]] = new JObject();
lastLevel = (JObject) lastLevel[nesting[i]];
}
}
}
serializer.Serialize(writer, main);
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
JsonSerializer serializer)
{
var jo = JToken.Load(reader);
object targetObj = Activator.CreateInstance(objectType);
foreach (PropertyInfo prop in objectType.GetRuntimeProperties().Where(p => p.CanRead && p.CanWrite))
{
var attributes = prop.GetCustomAttributes(true).ToArray();
JsonIgnoreAttribute ignoreAttribute = attributes.OfType<JsonIgnoreAttribute>().FirstOrDefault();
if (ignoreAttribute != null)
continue;
JsonPropertyAttribute att = attributes.OfType<JsonPropertyAttribute>().FirstOrDefault();
string jsonPath = att != null ? att.PropertyName : prop.Name;
if (serializer.ContractResolver is DefaultContractResolver resolver)
jsonPath = resolver.GetResolvedPropertyName(jsonPath);
if (!Regex.IsMatch(jsonPath, @"^[a-zA-Z0-9_.-]+$"))
throw new InvalidOperationException(
$"JProperties of JsonPathConverter can have only letters, numbers, underscores, hyphens and dots but name was ${jsonPath}."); // Array operations not permitted
JToken token = jo.SelectToken(jsonPath);
if (token != null && token.Type != JTokenType.Null)
{
object value;
var jsonConverterAttr = attributes.OfType<JsonConverterAttribute>().FirstOrDefault();
if (jsonConverterAttr == null)
{
value = token.ToObject(prop.PropertyType, serializer);
}
else
{
var converter = (JsonConverter) Activator.CreateInstance(jsonConverterAttr.ConverterType,
jsonConverterAttr.ConverterParameters);
var r = token.CreateReader();
r.Read();
value = converter.ReadJson(r, prop.PropertyType, prop.GetValue(targetObj),
new JsonSerializer());
}
prop.SetValue(targetObj, value, null);
}
}
return targetObj;
}
public override bool CanConvert(Type objectType)
{
// CanConvert is not called when [JsonConverter] attribute is used
return false;
}
}
And finally you can use it like this:
var json = "*your json string here*";
var customer = JsonConvert.DeserializeObject<Customer>(json);