9

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": "test2@consoto.com",
            "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.
Thiago Lunardi
  • 749
  • 1
  • 5
  • 19
  • Did you take a look into hal serialize: https://www.npmjs.com/package/hal-serializer ? – AhmedBinGamal Jan 14 '19 at 09:37
  • @AhmedBinGamal I will do, but that's a node solution, I'm looking for a c# permanent one. – Thiago Lunardi Jan 14 '19 at 10:02
  • You said you can't change your models, how about extending or overriding them? – Rob Jan 14 '19 at 10:07
  • It's going to be a problem. The scenario I have here is, full working and on a production system, and the source of data schema was changed to hal+json response. I can't go and change the struct of, or extend/override, a model - otherwise, I will need to touch that whole system. I need a pinpoint approach. Add some attributes - which I can do at runtime - or add `JsonConverter`, or other options that I did not figure out. – Thiago Lunardi Jan 14 '19 at 10:12
  • maybe just use manual LINQ to JSON -> objects version without any deserialization? It might be easier than writing deserializer – Krzysztof Skowronek Jan 14 '19 at 10:33
  • @KrzysztofSkowronek that's nicer than create multiple `EmbeddedN` types, for sure. But image an already existing system. I can't change the type of each model to `dynamic`. That will bring chaos, no doubt. :) – Thiago Lunardi Jan 14 '19 at 10:40
  • @ThiagoLunardi you create your `Customer` instance in LINQ, no problem. Just instead of creating N classes and use annotations to move data from JSON to your classes you can just use LINQ to fill the properties – Krzysztof Skowronek Jan 14 '19 at 10:55
  • @KrzysztofSkowronek may you reply this question with a sample. Looks interesting. – Thiago Lunardi Jan 14 '19 at 12:00

3 Answers3

6

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('.'))
        );
}
Nkosi
  • 235,767
  • 35
  • 427
  • 472
  • Also worked like a charm. You may see it working [here](https://dotnetfiddle.net/a6REyd). – Thiago Lunardi Jan 15 '19 at 09:39
  • @ThiagoLunardi note that with this example you can exclude the `JsonProperty` attributes as once they match the json naming they will be mapped automatically – Nkosi Jan 15 '19 at 09:46
  • I upvoted your answer because it works with less intervention than @Ivan one. I will wait until the bounty expiration for more answers if any. Thanks a lot, for now, kudos! – Thiago Lunardi Jan 15 '19 at 09:47
4

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);
Thiago Lunardi
  • 749
  • 1
  • 5
  • 19
Ivan Chepikov
  • 795
  • 4
  • 22
  • This worked as expected with very low intervention to models. Great! You can see working [here](https://dotnetfiddle.net/r1DPpy). I will try @Nkosi version now. – Thiago Lunardi Jan 15 '19 at 09:35
  • 1
    @Nkosi I reviewed the code of JsonPathConverter and realized that it could be implemented better. And [here](https://github.com/ichepikov/JsonNetExtension/blob/master/src/JsonNetExtension/Converters/NestedPropertyJsonConverter.cs) it is. According to my Benchmarks it works about 2 times faster and supports more Newtonsoft Json attributes like JsonIgnore and JsonConverter. – Ivan Chepikov Jan 16 '19 at 14:30
  • can I now run it without the `[JsonConverter(typeof(JsonPathConverter))]` decorator? – Thiago Lunardi Jan 17 '19 at 09:22
  • Ivan, you mean that you improved @Nkosi version? If so, that's great! – Thiago Lunardi Jan 17 '19 at 09:30
  • 1
    So true, I also did my own try and it's really way faster. Kudos @Ivan! – Thiago Lunardi Jan 17 '19 at 09:38
1

The company object will be under Embedded _embedded object.

like

    class Program
    {
        static void Main(string[] args)
        {
            string json = "{\"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\": \"test2@consoto.com\",\"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\"}}}";

            CustomerJson results = JsonConvert.DeserializeObject<CustomerJson>(json);
            Customer customer = new Customer()
            {
                Id = results.id,
                Country = results.country,
                Company = new LegalPerson()
                {
                    EmailAddresses = results._embedded.company._embedded.emailAddresses,
                    PhoneNumbers = results._embedded.company._embedded.phoneNumbers,
                    IndustrySector = results._embedded.company.industrySector,
                    Name = results._embedded.company.name,
                    Owner = results._embedded.company.owner
                }
            };

        }

    }

    public class EmbeddedContactInfoJson
    {
        public ContactInfo[] emailAddresses { get; set; }
        public ContactInfo[] phoneNumbers { get; set; }
    }
    public class CompanyJson
    {
        public string name { get; set; }
        public string industrySector { get; set; }
        public string owner { get; set; }
        public EmbeddedContactInfoJson _embedded { get; set; }
        public EmbeddedLinksJson _links { get; set; }
    }

    public class EmbeddedJson
    {
        public CompanyJson company { get; set; }
    }
    public class HrefJson
    {
        public string href { get; set; }
    }

    public class EmbeddedLinksJson
    {
        public HrefJson self { get; set; }
        public HrefJson phoneNumbers { get; set; }
        public HrefJson addresses { get; set; }
    }
    public class LinksJson
    {
        public HrefJson self { get; set; }
        public HrefJson legalPerson { get; set; }
        public HrefJson naturalPerson { get; set; }
    }
    public class CustomerJson
    {
        public Guid id { get; set; }
        public string country { get; set; }
        public EmbeddedJson _embedded { get; set; }
        public LinksJson _links { get; set; }
    }

    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; }
    }
Mohammad Ali Rony
  • 4,695
  • 3
  • 19
  • 33
  • No need to work out of the box, it can work with custom `JsonConverter` even. But I cannot change the models. That's a requirement. – Thiago Lunardi Jan 14 '19 at 10:00
  • Besides, will be an ugly solution to create a new `Embedded` type per model. Eg. `CompanyEmbbeded`, `LegalPersonEmbedded`, `OtherModelEmbedded` and on. – Thiago Lunardi Jan 14 '19 at 10:14
  • I understand the idea, I got your point. but there are 2 things: 1) I cannot change the models' structure, so I must restore the value to the currently existing models. 2) Having those `Self1`, `Self2`, `Embedded1`, `Embedded2` are really an ugly solution. I'm looking for something pinpoint. Eg: Creating a `[Embedded]` attribute and deal in a custom `JsonConverter`. I don't know yet how to solve it. – Thiago Lunardi Jan 14 '19 at 12:04
  • @ThiagoLunardi I have updated code.1) JSON Parse with this model using `JsonConverter` after that map the result with your existing model. 2) I have changed the model names as `Self1`, `Self2`. You can also change the class name as you wish, it won't be an issue. Please let me know if you have any question. – Mohammad Ali Rony Jan 14 '19 at 12:37
  • No `Embedded` class should be added because I cannot change the structure of the models. – Thiago Lunardi Jan 14 '19 at 12:50
  • @ThiagoLunardi II have update code at first Parse JSON with this JSON moded to own Class then map to currently existing models – Mohammad Ali Rony Jan 14 '19 at 14:12