Using Custom "ProblemDetailsFactory" (2/3)

Using Custom "ProblemDetailsFactory" (2/3)

Create custom Problem Details Factory in ASP.NET Core

This article shows, how one can return a structured response from an ASP.NET web API in case of errors (exceptions & model validations). One way to do that is by using custom ProblemDetailsFactory.

1. What is ProblemDetailsFactory?

ProblemDetailsFactory is an abstract class that defines 2 abstract methods:

  • CreateProblemDetails that returns an instance of ProblemDetails

  • CreateValidationProblemDetails that returns an instance of ValidationProblemDetails

ValidationProblemDetails is ProblemDetails for validation errors

These 2 methods are used to generate all instances of ProblemDetails and ValidationProblemDetails. These instances configure defaults, based on values specified in the ApiBehaviorOptions.

An implementation of ProblemDetailsFactory is provided by Microsoft that goes by the name: DefaultProblemDetailsFactory.

2. How is ProblemDetailsFactory related to error response?

As per MSDN

An error result is defined as a result with an HTTP status code of 400 or higher. For web API controllers, MVC transforms an error result to produce a ProblemDetails instance

The ProblemDetails response is returned by calling the CreateProblemDetails method of ProblemDetailsFactory class in the following cases:

  • Any IActionResult type that returns an HTTP status code of 400 or higher will be treated as an error result and a ProblemDetails instance will be returned as a response. For example, BadRequest, NotFound, StatusCode(StatusCodes.Status500InternalServerError) etc.

  • When an unhandled exception is thrown.

  • When we return Problem(), with or without params, from our controller's action methods.

Similarly ValidationProblemDetails response is returned by calling the CreateValidationProblemDetails method of ProblemDetailsFactory class in the following cases:

  • The [ApiController] attribute at the top of our web API controllers, automatically triggers an HTTP 400 response when model validation errors occur. And the default response type for an HTTP 400 response is ValidationProblemDetails. This response is generated by calling the InvalidModelStateResponseFactory, which then calls the CreateValidationProblemDetails method of the ProblemDetailsFactoy.

  • When we return ValidationProblem(), with or without params, from our controller's action methods.

3. Why do we need to write our own ProblemDetailsFactory ?

Although an error result is defined as a result with an HTTP status code of 400 or higher and for web API controllers, MVC transforms an error result to produce a ProblemDetails instance, ASP.NET Core doesn't produce a standardized error payload when an unhandled exception occurs - as per "Produce a ProblemDetails payload for exceptions" on MSDN.

Using custom ProblemDetailsFactory along with exception middleware we can easily tackle unhandled and custom exceptions as well as model-validation errors and return a structured and consistent response from across the application. And the code that makes all this happen is centralized, so we know where to look when we face any problem with our error responses.

4. How to handle custom and framework-level exceptions?

4.1. Custom Problem Details Factory

First, create a class, MyProblemDetailsFactory inheriting ProblemDetailsFactory class which enforces you to implement two abstract methods, one of them is CreateProblemDetails.

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding;

internal sealed class MyProblemDetailsFactory : ProblemDetailsFactory
{
    public override ProblemDetails CreateProblemDetails(
        HttpContext httpContext,
        Int32? statusCode = null,
        String? title = null,
        String? type = null,
        String? detail = null,
        String? instance = null)
    {
        ProblemDetails problemDetails;
        var exceptionHandlerFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
        if (exceptionHandlerFeature is not null &&
            exceptionHandlerFeature.Error is not null &&
            exceptionHandlerFeature.Error is MyExceptionBase myException)
        {   //<-- Custom exception handler
            httpContext.Response.StatusCode = myException.StatusCode;       //<-- Overrides the default 500 status code
            problemDetails = new ProblemDetails
            {
                Detail = myException.Detail,
                Instance = exceptionHandlerFeature.Path,
                Status = myException.StatusCode,
                Title = myException.Title
            };
        }
        else
        {   //<-- Default ProblemDetails instance, used for unhandled exceptions
            statusCode = statusCode ?? StatusCodes.Status500InternalServerError;
            detail = detail ?? "An error occurred, connect with your administrator.";
            title = title ?? "Internal Server Error";
            httpContext.Response.StatusCode = statusCode.Value;
            problemDetails = new ProblemDetails
            {
                Detail = detail,
                Instance = instance,
                Status = statusCode,
                Title = title,
                Type = type
            };
        }

        return problemDetails;
    }
}

4.2. Exception Handling Middleware

Configure UseExceptionHandler() middleware to call the specific action methods of ErrorsController.

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddSingleton<ProblemDetailsFactory, MyProblemDetailsFactory>();    //<-- Register custom ProblemDetailsFactory

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

//Configure middleware common to both development and non-development environments

app.Run();

4.3. Errors Controller

A few points to note about the ErrorsController:

  • There is no Route() attribute on the ErrorController, unlike other controllers. Because exception middleware will call a route of /error-development in the Development environment and a route of /error in non-Development environments.

  • Don't mark the error handler action method with HTTP method attributes, such as HttpGet. Explicit verbs prevent some requests from reaching the action method.

  • The attribute [ApiExplorerSettings(IgnoreApi = true)] excludes the error handler action from the app's Swagger / OpenAPI specification.

  • Allow anonymous access to the method if unauthenticated users should see the error.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

[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>()!;

        return Problem(
            detail: exceptionHandlerFeature.Error.StackTrace,
            title: exceptionHandlerFeature.Error.Message,
            instance: exceptionHandlerFeature.Path);
    }

    [Route("error")]
    [ApiExplorerSettings(IgnoreApi = true)]
    [AllowAnonymous]
    public IActionResult HandleError() => Problem();
}

5. How to handle model validation failures?

This is the same class: MyProblemDetailsFactory, but it only contains the overridden CreateValidationProblemDetails method. For full code, you can put both methods: CreateProblemDetails and CreateValidationProblemDetails in the same class, the one that inherits ProblemDetailsFactory class.

This will handle both, the inbuilt and custom validation errors.

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding;

internal sealed class MyProblemDetailsFactory : ProblemDetailsFactory
{
    public override ValidationProblemDetails CreateValidationProblemDetails(
        HttpContext httpContext,
        ModelStateDictionary modelStateDictionary,
        Int32? statusCode = null,
        String? title = null,
        String? type = null,
        String? detail = null,
        String? instance = null)
    {
        ArgumentNullException.ThrowIfNull(modelStateDictionary);

        statusCode ??= StatusCodes.Status400BadRequest;
        var validationProblemDetails = new ValidationProblemDetails(modelStateDictionary)
        {
            Status = statusCode,
            Type = type,
            Detail = detail,
            Instance = instance
        };
        if (title is not null)
        {
            // For validation problem details, don't overwrite the default title with null.
            validationProblemDetails.Title = title;
        }

        return validationProblemDetails;
    }
}

6. A few, not so Appealing Points as per my requirement

  • ProblemDetailsFactory may or may not be the right way for your application.

  • In case of model validation failure, the errors will be displayed in a particular way and that way cannot be modified. The Errors property is of type IDictionary<String, String[]> and one cannot change its type or remove it from the Extensions property of ProblemDetails instance (or at least I don't know it yet).

      {
        "title": "One or more validation errors occurred.",
        "status": 400,
        "errors": {
          "id": [
            "Must be greater than 0."
          ]
        }
      }
    

    For example, if I want to present errors in the following way, then it cannot be done.

      {
          "title": "One or more validation errors occurred.",
          "status": 400,
          "errors": [
              {
                  "name": "id",
                  "reason": "Must be greater than 0."
              }
          ]
      }
    

    To do this there is a workaround and it is certainly not pretty, as you can see the empty errors property exists.

      {
        "title": "One or more validation errors occurred.",
        "status": 400,
        "invalidParams": [
            {
                "name": "id",
                "reason": "Must be greater than 0."
            }
        ],
        "errors": {}
      }
    
  • In case you don't want to invoke the ProblemDetailsFactory methods by default, then read this.

7. References