Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Model Binding with Nested JSON Objects

I'm writing an endpoint to accept a POST request on a webhook from a 3rd party and the data they are sending is a JSON encoded body. So, I have no control over the data being sent to me and I need to handle it. My problem is that they do a lot of nesting in their JSON and since I'm only using a few of the keys they are sending me I don't want to create a bunch of unnecessary nested models just to get the data I want. Here is an example payload:

{
    id: "123456",
    user: {
        "name": {
            "first": "John",
            "Last": "Doe"
        }
    },
    "payment": {
        "type": "cash"
    }
}

and I want to put that in a model that looks like:

public class SalesRecord
{
    public string FirstName {get; set;}
    public string LastName {get; set;}
    public string PaymentType {get; set;}
}

Example of the endpoint (not much there yet):

[HttpPost("create", Name = "CreateSalesRecord")]
public ActionResult Create([FromBody] SalesRecord record)
{
    return Ok(record);
}

My past work has been in the Phalcon PHP Framework where I would generally just access the POST Body directly and set the values in the model myself. I certainly see the merits of model binding but I don't understand how to properly work around this situation yet.

like image 773
SethMc Avatar asked Jul 19 '18 21:07

SethMc


2 Answers

For a scenario like this one would need a custom model binder. The framework allows for such flexibility.

Using the walkthrough provided here

Custom model binder sample

and adapting it to this question.

The following sample uses the ModelBinder attribute on the SalesRecord model:

[ModelBinder(BinderType = typeof(SalesRecordBinder))]
[JsonConverter(typeof(JsonPathConverter))]
public class SalesRecord {
    [JsonProperty("user.name.first")]
    public string FirstName {get; set;}
    [JsonProperty("user.name.last")]
    public string LastName {get; set;}
    [JsonProperty("payment.type")]
    public string PaymentType {get; set;}
}

In the preceding code, the ModelBinder attribute specifies the type of IModelBinder that should be used to bind SalesRecord action parameters.

The SalesRecordBinder is used to bind an SalesRecord parameter by trying to parse the posted content using a custom JSON converter to simplify the deseiralization.

class JsonPathConverter : JsonConverter {
    public override object ReadJson(JsonReader reader, Type objectType, 
                                    object existingValue, JsonSerializer serializer) {
        JObject jo = JObject.Load(reader);
        object targetObj = Activator.CreateInstance(objectType);

        foreach (PropertyInfo prop in objectType.GetProperties()
                                                .Where(p => p.CanRead && p.CanWrite)) {
            JsonPropertyAttribute att = prop.GetCustomAttributes(true)
                                            .OfType<JsonPropertyAttribute>()
                                            .FirstOrDefault();

            string jsonPath = (att != null ? att.PropertyName : prop.Name);
            JToken token = jo.SelectToken(jsonPath);

            if (token != null && token.Type != JTokenType.Null) {
                object value = token.ToObject(prop.PropertyType, serializer);
                prop.SetValue(targetObj, value, null);
            }
        }
        return targetObj;
    }

    public override bool CanConvert(Type objectType) {
        // CanConvert is not called when [JsonConverter] attribute is used
        return false;
    }

    public override bool CanWrite {
        get { return false; }
    }

    public override void WriteJson(JsonWriter writer, object value, 
                                    JsonSerializer serializer) {
        throw new NotImplementedException();
    }
}

Source: Can I specify a path in an attribute to map a property in my class to a child property in my JSON?

public class SalesRecordBinder : IModelBinder {

    public Task BindModelAsync(ModelBindingContext bindingContext) {
        if (bindingContext == null){
            throw new ArgumentNullException(nameof(bindingContext));
        }

        // Try to fetch the value of the argument by name
        var valueProviderResult = bindingContext.ValueProvider
            .GetValue(bindingContext.ModelName);

        if (valueProviderResult == ValueProviderResult.None){
            return Task.CompletedTask;
        }

        var json = valueProviderResult.FirstValue;

        // Check if the argument value is null or empty
        if (string.IsNullOrEmpty(json)) {
            return Task.CompletedTask;
        }

        //Try to parse the provided value into the desired model
        var model = JsonConvert.DeserializeObject<SalesRecord>(json);

        //Model will be null if unable to desrialize.
        if (model == null) {
            bindingContext.ModelState
                .TryAddModelError(
                    bindingContext.ModelName,
                    "Invalid data"
                );
            return Task.CompletedTask;
        }

        bindingContext.ModelState.SetModelValue(bindingContext.ModelName, model);

        //could consider checking model state if so desired.

        //set result state of binding the model
        bindingContext.Result = ModelBindingResult.Success(model);
        return Task.CompletedTask;
    }
}

From there it should be now a simple matter of using the model in the action

[HttpPost("create", Name = "CreateSalesRecord")]
public IActionResult Create([FromBody] SalesRecord record) {
    if(ModelState.IsValid) {
        //...
        return Ok();
    }

    return BadRequest(ModelState);
}

Disclaimer: This has not been tested as yet. There may be issues still to be fixed as it is based on the linked sources provided above.

like image 174
Nkosi Avatar answered Nov 04 '22 04:11

Nkosi


Note: this assumes the JSON input will always be valid. You will have to add some checking if this is not true.

If you don't want to make this too complex, you can use the help of the DLR. The NewtonSoft.Json serializer allows you to de-serialize into dynamic objects:

[HttpPost]
public IActionResult CreateSalesRecord([FromBody]dynamic salesRecord)
{
    return Ok(new SalesRecord
    {
        FirstName = salesRecord.user.name.first,
        LastName = salesRecord.user.name.Last,
        PaymentType = salesRecord.payment.type
    });
}
like image 3
Camilo Terevinto Avatar answered Nov 04 '22 04:11

Camilo Terevinto