Using Custom "ProblemDetailsFactory" (2/3)
Table of contents
- 1. What is ProblemDetailsFactory?
- 2. How is ProblemDetailsFactory related to error response?
- 3. Why do we need to write our own ProblemDetailsFactory ?
- 4. How to handle custom and framework-level exceptions?
- 5. How to handle model validation failures?
- 6. A few, not so Appealing Points as per my requirement
- 7. References
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 ofProblemDetails
CreateValidationProblemDetails
that returns an instance ofValidationProblemDetails
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 aProblemDetails
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 isValidationProblemDetails
. This response is generated by calling theInvalidModelStateResponseFactory
, which then calls theCreateValidationProblemDetails
method of theProblemDetailsFactoy
.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 theErrorController
, 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
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 typeIDictionary<String, String[]>
and one cannot change its type or remove it from theExtensions
property ofProblemDetails
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": {} }
NOTE: In case you don't want to invoke the ProblemDetailsFactory
methods by default, then read this.
7. References
Subscribe to my newsletter
Read articles from Vedant Phougat directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Vedant Phougat
Vedant Phougat
Senior Software Engineer who likes writing clean, high performing and fully tested code in C# using .NET and ASP.NET Core.