6

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.

SethMc
  • 119
  • 1
  • 10
  • 2
    You will have to create a custom model binder to parse and bind your model. – Nkosi Jul 19 '18 at 21:29
  • 1
    Show the desired code for your endpoint – Nkosi Jul 19 '18 at 21:31
  • Reference [Model Binding in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/mvc/models/model-binding?view=aspnetcore-2.1), [Custom Model Binding in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/mvc/advanced/custom-model-binding?view=aspnetcore-2.1) – Nkosi Jul 19 '18 at 21:52
  • I haven't really written the endpoint yet, at this point I'm just trying to get the data parsed properly. See update – SethMc Jul 19 '18 at 22:09
  • I am reviewing it now. I would suggest you take some time and review the second link I posted above. They walk you through how to create a custom binder. – Nkosi Jul 19 '18 at 22:20

3 Answers3

5

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.

Nkosi
  • 235,767
  • 35
  • 427
  • 472
3

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
    });
}
Camilo Terevinto
  • 31,141
  • 6
  • 88
  • 120
  • 1
    Not a big fan of the dynamic route because of the dynamic knock on effects. Did you look into the custom model binder? – Nkosi Jul 19 '18 at 21:50
  • @Nkosi I'm not a fan either, and certainly wouldn't go this way in production code. I'd rather have models that match the input rather than custom binders though – Camilo Terevinto Jul 19 '18 at 21:52
  • You can still have models, you would just be binding then using a custom binder. that framework allows that flexibility. I added some links in the comments of the OP if you want to take a look. – Nkosi Jul 19 '18 at 21:54
  • @Nkosi Thanks, I'll keep that in mind, it's an interesting approach – Camilo Terevinto Jul 19 '18 at 22:03
  • @Nkosi Could you elaborate on the "dynamic knock on effects" you refer to? – spencer741 Feb 09 '21 at 22:41
0
[HttpPost]
    public IActionResult Json(string json)
    {
        JObject j = JObject.Parse(json);
        MyModel m = j.ToObject<MyModel>();
        return View();
    }

You could try this if Your Json is in string format. I believe for it to work Your data model will have to be exact representation of Json.