Using Result Filter and Exception Middleware (3/3)

Create a custom Result Filter to handle model-validation errors and Exception Middleware for exceptions

Using Result Filter and Exception Middleware (3/3)

1. Introduction

In this article, I will describe how one can use a custom result filter and exception middleware to handle model-validation errors and exceptions (custom as well as framework exceptions).

For the complete code, see the project: ViaFilterAndMiddleware and for its test cases see the project: ViaFilterAndMiddlewareTest

2. Custom result filter for model-validation errors

We will first create a custom result filter for handling model-validation errors and then test it.

Why use a custom result filter for handling model-validation errors? Because:

  • In ASP.NET 6.0, upon model-validation errors, none of the methods; OnActionExecuting or OnActionExecuted of IActionFilter is invoked. Whereas, first OnResultExecuting and then OnResultExecuted method of IResultFilter is invoked.

  • It provides fine-grained control over the execution. So, one can choose to register it globally, builder.Services.AddControllers(options => options.Filters.Add<MyModelValidationResultFilter>());, which will invoke this filter on every call made to our application, or we can apply this as an attribute on specific action methods and controllers (doing this will enforce the filter on all action methods of that controllers).

Our custom result filter is registered globally, why? Because registering it globally will invoke it on every call, thus making sure if the model-validation failure has occurred or not. If it has then we can modify the response structure accordingly and not expose any information that we don't want to.

2.1. Create a Custom Result Filter

Create a class: MyModelValidationResultFilter, and implement IResultFilter on it. Now we move ahead step-by-step to understand what we are doing.

2.1.1. When the model is valid

In case, the model is valid, execution must return immediately

if (context.ModelState.IsValid)
    return;

2.1.2. When model-binding fails

Consider the following view model:

public class OrderViewModel
{
    public Int32 Id { get; set; }
    public Dictionary<Int32, Int32>? ProductQuantityMap { get; set; }
}

And the following action method:

public class OrdersController : ControllerBase
{
    public IActionResult Create(OrderViewModel viewModel)
    {
        var id = _orderService.Create(viewModel);
        return StatusCode(Status201Created, id);
    }
}

Now let's say one of the following request payloads is provided to the above-mentioned API:

  • null

  • { null }

  • { "null": "" }

In all these scenarios, the model-binding will fail. In the first case, upon model-binding failure, the value of the key is empty, "" and in the second and third cases, the value of the key is the $ sign. To handle these cases we have used the following code:

//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;
}

Take a gander at this StackOverflow question.

2.1.3. When a specific property-binding of the model, failed

Consider the following action method:

public class OrdersController : ControllerBase
{
    public IActionResult Create(OrderViewModel viewModel)
    {
        var id = _orderService.Create(viewModel);
        return StatusCode(Status201Created, id);
    }
}

And consider the following request payloads, which are provided via Swagger/Postman:

//1.
{
    "id": 1,
    "productQuantityMap": null
}

//2.
{
    "id": 1,
    "productQuantityMap": {
        null
    }
}

The above-mentioned request payload will generate a model-validation error, because of the model-binding failure which will generate "$.productQuantityMap" as the error key. Now, to handle this scenario we have used the following code:

//When a specific property-binding fails
var keyValuePair = modelStateDictionary.FirstOrDefault(pair => pair.Key.Contains("$."));
{
    var propertyName = keyValuePair.Key.Split(Dot)[1];
    problemDetails.Detail = String.IsNullOrEmpty(propertyName) ?
        RequestFailedModelBinding :
        String.Format(RequestPropertyFailedModelBinding, propertyName);
    context.Result = GetBadRequestObjectResult(problemDetails);
    return;
}

2.1.4. When model validation error(s) occurs

//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);

This above-mentioned piece of code handles model validation error(s).

2.2. Test the Custom Result Filter

For unit testing, I follow the naming convention suggested by Steve "Ardalis" in this article. Here is the link to the unit-test cases of MyModelValidationResultFilter.

3. Use Exception Middleware to handle all exceptions

For a detailed explanation, visit handling errors in ASP.NET Core Web API. Here we will handle errors using an ErrorsController. We will need to follow these 2 steps:

Step 1: Create ErrorsController with 2 action methods: HandleErrorDevelopment, for catering requests in development environments and HandleError, for catering requests in non-development environments.

The HandleErrorDevelopment action method will be invoked when an exception is thrown in the development environment. It handles custom as well as framework exceptions based on the value of exceptionHandlerFeature and exceptionHandlerFeature.Error.

The HandleError action method will be invoked when an exception is thrown in the non-development environment. In case of custom exceptions, it will return an appropriate response to the client and for all other exceptions, it will return a ProblemDetails instance with Internal Server Error Response.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using WebApi.ErrorResponse.ViaFilterAndMiddleware.Exceptions;
using static Microsoft.AspNetCore.Http.StatusCodes;

[Route("api/[controller]")]
[ApiController]
public class ErrorsController : ControllerBase
{
    [Route("error-development")]
    [ApiExplorerSettings(IgnoreApi = true)]
    public IActionResult HandleErrorDevelopment([FromServices] IHostEnvironment hostEnvironment)
    {
        if (hostEnvironment is not null && !hostEnvironment.IsDevelopment())
            return NotFound();

        var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!;
        if (exceptionHandlerFeature is null ||
            exceptionHandlerFeature.Error is null)
        {

            /*
              Returns internal server error in case this
              action method is called but "exceptionHandlerFeature" or
              "exceptionHandlerFeature.Error" is null
            */
            return Problem(
                title: "Internal Server Error",
                detail: "You are on your own, now start debugging.",
                statusCode: Status500InternalServerError);            
        }

        //Handles custom exceptions
        if (exceptionHandlerFeature.Error is MyExceptionBase myException)
        {
            return Problem(
                detail: myException.Detail,
                title: myException.Title,
                statusCode: myException.StatusCode,
                instance: exceptionHandlerFeature.Path);
        }

        //Handles framework exceptions
        return Problem(
            detail: exceptionHandlerFeature.Error.StackTrace,
            title: exceptionHandlerFeature.Error.Message,
            statusCode: Status500InternalServerError,
            instance: exceptionHandlerFeature.Path);
    }

    [Route("error")]
    [ApiExplorerSettings(IgnoreApi = true)]
    [AllowAnonymous]
    public IActionResult HandleError()
    {
        var exceptionHandlerFeature = HttpContext.Features.Get<IExceptionHandlerFeature>()!;
        if (exceptionHandlerFeature is not null &&
            exceptionHandlerFeature.Error is not null &&
            exceptionHandlerFeature.Error is MyExceptionBase myException)
        {    //<--Only handles custom exceptions
            return Problem(
                detail: myException.Detail,
                title: myException.Title,
                statusCode: myException.StatusCode,
                instance: exceptionHandlerFeature.Path);
        }

        return Problem(    //<--Handles all other exceptions
            title: "Internal Server Error",
            detail: "Internal server error occurred, contact your admin.",
            statusCode: Status500InternalServerError);
    }
}

Step 2: Configure UseExceptionHandler middleware to call specific action methods of an ErrorController.

⚠️ Be very cautious when passing the development error API path to the exception middleware, triple check it. This app.UseExceptionHandler("/error-development"); must be inside the block which will be invoked in a development environment only.

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error-development");
    //Configure middleware that are specific to development environment.
}
else
{
    app.UseExceptionHandler("/error");
    //Configure middleware that are specific to non-development environment.
}

/*
    Configure middleware that are common to both
    development and non-development environments
 */

app.Run();

4. My Take on it

I would prefer the Result Filter and exception middleware over custom ProblemDetailsFactory, to handle error response in ASP.NET Core Web API. Your preference can be completely different which is perfectly fine. Because it depends on what the client/user finds more appealing.

You may ask, why do I prefer Result Filter and exception middleware over custom ProblemDetailsFactory?

✅In case of model validation failures, the Result Filter gives more control over the execution as well as the structure of the result when compared to the ValidationProblemDetails instance of the ProblemDetailsFactory. With more control comes more responsibilities, so make sure to write enough unit-test cases to cover edge cases as well.

❌ What if you want to change the structure of the error property in the ValidationProblemDetails instance? Bad news, it cannot be done, neither it can be removed. For more details about it read this.

❌ And if you want to use custom ProblemDetailsFactory, you will be enforced to return an instance of ValidationProblemDetails instance upon model validation failure, which will enforce the previous point.