3

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?

phougatv
  • 881
  • 2
  • 12
  • 29
  • That looks like invalid json – Daniel A. White Jun 04 '23 at 19:17
  • @DanielA.White And that is why model-binding fails. Although failure in model-binding is considered a part of model-validation failure. I am trying to distinguish model-binding failure from model-validation failure, so that I can do one thing when model-binding failure occurs and other thing when model-validation failure occurs. – phougatv Jun 04 '23 at 19:28
  • Is there anything you couln't do after validation failure? – Qiang Fu Jun 05 '23 at 02:35
  • @QiangFu, I want to return a specific `ProblemDetails` instance when model binding fails and in case of validation attribute failure, I want to return the property name, along with its error message. – phougatv Jun 05 '23 at 02:58

2 Answers2

0

ModelState.key is the invalid property name. You send null so the property name is empty. But in my test, the key is a "$"
You can try the following code.
ViewModel.cs

    public class ViewModel
    {
        public int Number {  get; set; }
    }

ProblemDetails.cs

    public class ProblemDetails
    {
        public string Type { get; set; }
        public string Title { get; set; }
        public int Status { get; set; }
        public string Detail { get; set; }
        public string Instance { get; set; }
        public Dictionary<string, string> Extensions { get; set; }
    }

Program.cs

builder.Services.Configure<ApiBehaviorOptions>(options
    => options.SuppressModelStateInvalidFilter = true);

Controller

        [HttpPost("test")]
        public IActionResult test(ViewModel viewModel)
        {
            if (!ModelState.IsValid)
            {
                var problemDetails = new ProblemDetails
                {
                    Type = "123",
                    Title = "Invalid parameters",
                    Status = StatusCodes.Status400BadRequest,
                    Detail = "Your request parameters didn't validate.",
                    Instance = "",
                    Extensions = new Dictionary<string, string>()
                };
                foreach (var b in ModelState)
                {
                    problemDetails.Extensions.Add(b.Key, b.Value.Errors.FirstOrDefault().ErrorMessage);
                }
                return Ok(problemDetails);
            }
            return Ok(viewModel);           
        }

Test
enter image description here
enter image description here

You can put the codes in ActionFilter.
ValidationFilterAttribute.cs

    public class ValidationFilterAttribute : IActionFilter
    {
        public void OnActionExecuting(ActionExecutingContext context)
        {
            if (!context.ModelState.IsValid)
            {
                var problemDetails = new ProblemDetails
                {
                    Type = "123",
                    Title = "Invalid parameters",
                    Status = StatusCodes.Status400BadRequest,
                    Detail = "Your request parameters didn't validate.",
                    Instance = "",
                    Extensions = new Dictionary<string, string>()
                };
                foreach (var b in context.ModelState)
                {
                    problemDetails.Extensions.Add(b.Key, b.Value.Errors.FirstOrDefault().ErrorMessage);
                }
                context.Result = new ObjectResult(problemDetails);
            }
        }
        public void OnActionExecuted(ActionExecutedContext context) { }
    }

program.cs

builder.Services.AddScoped<ValidationFilterAttribute>();
builder.Services.Configure<ApiBehaviorOptions>(options
    => options.SuppressModelStateInvalidFilter = true);

Controller

        [HttpPost("test")]
        [ServiceFilter(typeof(ValidationFilterAttribute))]
        public IActionResult test(ViewModel viewModel)
        {
            return Ok(viewModel);           
        }

Result filter vs Action filter ActionFilter and ResultFilter different and Examples

Qiang Fu
  • 1,401
  • 1
  • 2
  • 8
0

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;
    }
}
phougatv
  • 881
  • 2
  • 12
  • 29