Search code examples
c#asp.net-coreasp.net-core-webapimodel-bindingmodel-validation

What is the best way to identify that a model binding has failed in ASP.NET Core 6.0+


I have gone through the MSDN documentation:

I tried creating a scenario where value sent from the swagger to the API, failed to bind to the model, that is expected on the server. Here is the code of the scenario:

OrderController.cs

[HttpPost]
public async Task<IActionResult> CreateAsync(OrderViewModel viewModel)
{
    //map and add this model to the db
    //and return a 201 status code
}

And the input I sent from the swagger:

{
    null
}

This led to the model-binding failure, and I have a result filter where I am handling this situation as follows: FailedValidationResultFilter

public class FailedValidationResultFilter : IResultFilter
{
    public void OnResultExecuted(ResultExecutedContext context)
    {
    }

    public void OnResultExecuting(ResultExecutingContext context)
    {
        //When model-binding fails
        var hasModelBindingFailed = context.ModelState.Any(pair => String.IsNullOrEmpty(pair.Key));
        if (hasModelBindingFailed)
        {
            //do something when model-binding fails.
            //and return BadRequestObjectResult
        }

        //When validation attributes fails
        var invalidParams = new Dictionary<String, String[]>(context.ModelState.Count);
        foreach (var keyModelStatePair in context.ModelState)
        {
            var key = keyModelStatePair.Key;
            var modelErrors = keyModelStatePair.Value.Errors;

            if (modelErrors is not null && modelErrors.Count > 0)
            {
                var errorMessages = modelErrors.Select(error => error.ErrorMessage).ToArray();
                invalidParams.Add(key, errorMessages);
            }
        }

        var problemDetails = new ProblemDetails
        {
            Type = "123",
            Title = "Invalid parameters",
            Status = StatusCodes.Status400BadRequest,
            Detail = "Your request parameters didn't validate.",
            Instance = ""
        };
        problemDetails.Extensions.Add(nameof(invalidParams), invalidParams);

        context.Result = new BadRequestObjectResult(problemDetails);
    }
}

What I have observed while debugging is this, that whenever model-binding fails for this input, it returns 2 key value pair:

  • { "", "Some error message" }
  • { "viewModel", "Again some error message" }

So, I am checking if their is a model-state with an empty key, if it is then there is a model-binding error. And I am not sure why, but it just doesn't feel like the right approach to find if model-binding has failed.

Question: what is the correct way to identify if model binding has failed? What could be another input type that can be passed which leads to failure in model-binding and then in the filter, the first property may not be blank/empty as I am expecting it to be?


Solution

  • After doing much test and trial, I hope I have the correct answer. So, let's begin.

    Scenario One

    When Request Payload is

    null
    

    When we send this payload in the request, the model-validation fails generating 2 keys (one of them is an empty string) with the following error-messages:

    Key Error Message
    A non-empty request body is required.
    viewModel The viewModel field is required.

    Scenario Two

    When Request Payload is

    {
        null
    }
    

    In this case these 2 keys are generated:

    Key Error Message
    $ 'n' is an invalid start of a property name. Expected a '"'. Path: $ | LineNumber: 1 | BytePositionInLine: 2.
    viewModel The viewModel field is required.

    Now, I have used the following piece of code to handle both the scenarios:

    //When model-binding fails because input is an invalid JSON
    if (modelStateDictionary.Any(pair => pair.Key == DollarSign || String.IsNullOrEmpty(pair.Key)))
    {
        problemDetails.Detail = RequestFailedModelBinding;
        context.Result = GetBadRequestObjectResult(problemDetails);
        return;
    }
    

    Complete code:

    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.Filters;
    using WebApi.ErrorResponse.ViaFilterAndMiddleware.ViewModels;
    using static Microsoft.AspNetCore.Http.StatusCodes;
    
    public class MyModelValidationResultFilter : IResultFilter
    {
        #region Private Constants
        private const Char Dot = '.';
        private const String DollarSign = "$";
        private const String InvalidParameters = "Invalid parameters.";
        private const String RequestFailedModelBinding = "Your request failed model-binding.";
        private const String RequestPropertyFailedModelBinding = "Your request failed model-binding: '{0}'.";
        private const String RequestParametersDidNotValidate = "Your request parameters did not validate.";
        private const String MediaTypeApplicationProblemJson = "application/problem+json";
        #endregion Private Constants
    
        /// <summary>
        /// 
        /// </summary>
        /// <param name="context">The result executed context.</param>
        public void OnResultExecuted(ResultExecutedContext context)
        {
    
        }
    
        /// <summary>
        /// 
        /// </summary>
        /// <param name="context">The result executing context.</param>
        public void OnResultExecuting(ResultExecutingContext context)
        {
            if (context.ModelState.IsValid)
                return;
    
            var modelStateDictionary = context.ModelState;
            var problemDetails = new ProblemDetails
            {
                Title = InvalidParameters,
                Status = Status400BadRequest
            };
    
            //When model-binding fails because input is an invalid JSON
            if (modelStateDictionary.Any(pair => pair.Key == DollarSign || String.IsNullOrEmpty(pair.Key)))
            {
                problemDetails.Detail = RequestFailedModelBinding;
                context.Result = GetBadRequestObjectResult(problemDetails);
                return;
            }
    
            //When a specific property-binding fails
            var keyValuePair = modelStateDictionary.FirstOrDefault(pair => pair.Key.Contains("$."));
            if (keyValuePair.Key is not null)
            {
                var propertyName = keyValuePair.Key.Split(Dot)[1];
                problemDetails.Detail =
                    String.IsNullOrEmpty(propertyName) ? RequestFailedModelBinding : String.Format(RequestPropertyFailedModelBinding, propertyName);
                context.Result = GetBadRequestObjectResult(problemDetails);
                return;
            }
    
            //When one of the input parameters failed model-validation
            var invalidParams = new List<InvalidParam>(modelStateDictionary.Count);
            foreach (var keyModelStatePair in modelStateDictionary)
            {
                var key = keyModelStatePair.Key;
                var modelErrors = keyModelStatePair.Value.Errors;
                if (modelErrors is not null && modelErrors.Count > 0)
                {
                    IEnumerable<InvalidParam> invalidParam;
                    if (modelErrors.Count == 1)
                    {
                        invalidParam = modelErrors.Select(error => new InvalidParam(keyModelStatePair.Key, new[] { error.ErrorMessage }));
                    }
                    else
                    {
                        var errorMessages = new String[modelErrors.Count];
                        for (var i = 0; i < modelErrors.Count; i++)
                        {
                            errorMessages[i] = modelErrors[i].ErrorMessage;
                        }
    
                        invalidParam = modelErrors.Select(error => new InvalidParam(keyModelStatePair.Key, errorMessages));
                    }
    
                    invalidParams.AddRange(invalidParam);
                }
            }
    
            problemDetails.Detail = RequestParametersDidNotValidate;
            problemDetails.Extensions[nameof(invalidParams)] = invalidParams;
            context.Result = GetBadRequestObjectResult(problemDetails);
        }
    
        /// <summary>
        /// Creates <see cref="BadRequestObjectResult"/> instance.
        /// The content-type is set to: 'application/problem+json'
        /// </summary>
        /// <param name="problemDetails">The problem details instance.</param>
        /// <returns>The bad request object result instance.</returns>
        private static BadRequestObjectResult GetBadRequestObjectResult(ProblemDetails problemDetails)
        {
            var result = new BadRequestObjectResult(problemDetails);
            result.ContentTypes.Clear();
            result.ContentTypes.Add(MediaTypeApplicationProblemJson);
            return result;
        }
    }