Search code examples
c#jsonjson.netdeserializationjson-deserialization

Deserialize hal+json to complex model


I have the following HAL+JSON sample:

{
    "id": "4a17d6fe-a617-4cf8-a850-0fb6bc8576fd",
    "country": "DE",
    "_embedded": {
      "company": {
        "name": "Apple",
        "industrySector": "IT",
      "owner": "Klaus Kleber",
      "_embedded": {
        "emailAddresses": [
          {
            "id": "4a17d6fe-a617-4cf8-a850-0fb6bc8576fd",
            "value": "[email protected]",
            "type": "Business",
            "_links": {
              "self": {
                "href": "https://any-host.com/api/v1/customers/1234"
              }
            }
          }
        ],
        "phoneNumbers": [
          {
            "id": "4a17d6fe-a617-4cf8-a850-0fb6bc8576fd",
            "value": "01670000000",
            "type": "Business",
            "_links": {
              "self": {
                "href": "https://any-host.com/api/v1/customers/1234"
              }
            }
          }
        ],
      },
      "_links": {
        "self": {
          "href": "https://any-host.com/api/v1/customers/1234"
        },
        "phoneNumbers": {
          "href": "https://any-host.com/api/v1/customers/1234"
        },
        "addresses": {
          "href": "https://any-host.com/api/v1/customers/1234"
        },
      }
    },
  },
  "_links": {
    "self": {
      "href": "https://any-host.com/api/v1/customers/1234"
    },
    "legalPerson": {
      "href": "https://any-host.com/api/v1/customers/1234"
    },
    "naturalPerson": {
      "href": "https://any-host.com/api/v1/customers/1234"
    }
  }
}

And the following models:

public class Customer
{
    public Guid Id { get; set; }
    public string Country { get; set; }
    public LegalPerson Company { get; set; }
}
public class LegalPerson
{
    public string Name { get; set; }
    public string IndustrySector { get; set; }
    public string Owner { get; set; }
    public ContactInfo[] EmailAddresses { get; set; }
    public ContactInfo[] PhoneNumbers { get; set; }
}
public class ContactInfo
{
    public Guid Id { get; set; }
    public string Type { get; set; }
    public string Value { get; set; }
}

Now, because of the _embbeded, I can't do an out-of-the-box serialization with Newtonsoft.Json, because then Company will be null;

I was hoping to see a native hal+json support by Json.NET, but it only has one recommendation to use a custom JsonConverter.

I started to create a custom one by myself, but feels like "reinventing the wheel" for me.

So, anyone knows a smart way to get out with this?

UPDATE:

  • It's important to not change the models/classes. I can add attributes, but never change it's structures.

Solution

  • 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('.'))
            );
    }