Handling Model Binding Errors when using [FromBody] in .NET Core 2.1
Asked Answered
A

2

13

I am trying to understand how I can intercept and handle model binding errors in .net core.

I want to do this:

    // POST api/values
    [HttpPost]
    public void Post([FromBody] Thing value)
    {
        if (!ModelState.IsValid)
        {
            // Handle Error Here
        }
    }

Where the Model for "Thing" is:

public class Thing
{
    public string Description { get; set; }
    public int Amount { get; set; }
}

However if I pass in an invalid amount like:

{ 
   "description" : "Cats",
   "amount" : 21.25
}

I get an error back like this:

{"amount":["Input string '21.25' is not a valid integer. Path 'amount', line 1, position 38."]}

Without the controller code ever being hit.

How can I customise the error being sent back? (as basically I need to wrap this serialisation error in a larger error object)

Arboreous answered 11/3, 2019 at 11:7 Comment(0)
A
26

So, I missed this before but I have found here:

https://learn.microsoft.com/en-us/aspnet/core/web-api/index?view=aspnetcore-2.2#automatic-http-400-responses

That if you use the

[ApiController] 

attribute on your controller, it will automatically handle serialisation errors and provide the 400 response, equivalent to:

if (!ModelState.IsValid)
{
    return BadRequest(ModelState);
}

You can turn this behaviour off in the Startup.cs like this:

services.AddMvc()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.SuppressModelStateInvalidFilter = true;
    });

If you are looking to customise the response, a better option is to use a InvalidModelStateResponseFactory, which is a delegate taking an ActionContext and returning an IActionResult which will be called to handle serialisation errors.

See this example:

services.Configure<ApiBehaviorOptions>(options =>
{
    options.InvalidModelStateResponseFactory = actionContext => 
    {
        var errors = actionContext.ModelState
            .Where(e => e.Value.Errors.Count > 0)
            .Select(e => new Error
            {
            Name = e.Key,
            Message = e.Value.Errors.First().ErrorMessage
            }).ToArray();

        return new BadRequestObjectResult(errors);
    }
});
Arboreous answered 11/3, 2019 at 11:55 Comment(4)
The better approach is to supply a custom handler via options.InvalidModelStateResponseFactory. That way, you still don't need to check ModelState.IsValid all over the place, and you can still return your custom response.Hebraist
Sorry. Missed the last line. But, yeah, use the InvalidModelStateResponseFactory. That's the way to go.Hebraist
This is the best answer I could find. However if there is an error during model binding - like trying to bind an integer to a guid, aspnet bypasses the InvalidModelStateResponseFactory and returns the default 400 bad response. I can't find any way to customise the erro. using SuppressModelStateInvalidFilter just gives you a null model and no info on why the model didn't bind.Acrosstheboard
If you are gonna implement new InvalidModelStateResponseFactory make sure you DON'T set SuppressModelStateInvalidFilter to true it must remains false.Testaceous
S
2

The framework uses Model Binders to map the request strings into a complex object, so my guess is that you will need to create a Custom Model Binder. Please refer Custom Model Binding in ASP.Net Core

But before that, an easier way to try would be to try Binder attributes in your models. BindRequired attribute adds a model state error if binding cannot occur. So you can modify your model as :

public class Thing 
{
    [BindRequired]
    public string Description {get;set;}

    [BindRequired]
    public int Amount {get;set;}
}

If that doesn't work for you, then you can try to create a custom model binder. An example from the article :

[ModelBinder(BinderType = typeof(AuthorEntityBinder))]
public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string GitHub { get; set; }
    public string Twitter { get; set; }
    public string BlogUrl { get; set; }
}

public class AuthorEntityBinder : IModelBinder
{
   private readonly AppDbContext _db;
   public AuthorEntityBinder(AppDbContext db)
   {
       _db = db;
   }

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

    var modelName = bindingContext.ModelName;

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

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

    bindingContext.ModelState.SetModelValue(modelName,
        valueProviderResult);

    var value = valueProviderResult.FirstValue;

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

    int id = 0;
    if (!int.TryParse(value, out id))
    {
        // Non-integer arguments result in model state errors
        bindingContext.ModelState.TryAddModelError(
                                modelName,
                                "Author Id must be an integer.");
        return Task.CompletedTask;
    }

    // Model will be null if not found, including for 
    // out of range id values (0, -3, etc.)
    var model = _db.Authors.Find(id);
    bindingContext.Result = ModelBindingResult.Success(model);
    return Task.CompletedTask;
   }
}

You might also want to look at Model Validation

Scoter answered 11/3, 2019 at 11:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.