Problem Details with ASP.NET Core
I read this the other day and it made total sense to me:
Source: https://twitter.com/codeopinion/status/1381260308854026247
If there is an existing standard, we should look into using it.
Background
Standardizing within software development is a tricky subject. That is because it is really difficult to agree on a standard. Before you know it you have multiple ‘standards’ to chose from. In my experience, the RFC documents from the Internet Engineering Task Force (IETF) are a good place to start. These documents are well thought out, reviewed by a lot of people and easy to understand (for the most part).
So I was (pleasantly) surprised to find out they had an RFC document describing a common way of returning errors from an HTTP API.
I found a great blog post Luis Ruiz had written about this topic. He provides a good explanation of the problem being addressed and demonstrates some good approaches to using it with ASP.NET Core.
Problem Details for HTTP APIs
The abstract for RFC document 7807 is very clear:
This document defines a “problem detail” as a way to carry machine-readable details of errors in a HTTP response to avoid the need to define new error response formats for HTTP APIs.
For example, an HTTP response carrying JSON problem details:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc",
"balance": 30,
"accounts": ["/account/12345", "/account/67890"]
}
The RFC describes the members of the problem details object as:
- type (string) - A URI reference that identifies the problem type.
- title (string) - A short, human-readable summary of the problem
- status (number) - The HTTP status code generated by the origin server for this occurrence of the problem.
- detail (string) - A human-readable explanation specific to this occurrence of the problem.
- instance (string) - A URI reference that identifies the specific occurrence of the problem.
The RFC stipulates that extensions can be added to the problem details (as the “balance” amd “accounts” members do in the above example).
Using Problem Details in ASP.NET Core
Since ASP.NET 2.1, ASP.NET Core has included an implementation of the Problem Details. Here is an example:
var problemDetails = new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = "One or more validation error occurred.",
Status = StatusCodes.Status400BadRequest,
Instance = HttpContext.Request.Path,
Extensions = { {"errors", errors} }
};
return new ObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" },
StatusCode = StatusCodes.Status400BadRequest
};
While it is possible to use the ProblemDetails in this manner, there are a lot of helper classes to make things simpler.
For validation errors:
ModelState.AddModelError("customer", "missing_first_name");
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
For a global error handler, you can return the standard response:
[ApiController]
public class ErrorController : ControllerBase
{
[Route("/error")]
public IActionResult Error() => Problem();
}
But to really do it properly it takes a lot of care. Thankfully, there is a NuGet package for that.
Hellang.Middleware.ProblemDetails
This library provides what I would call the ‘expected default behavior’ for how to handle errors.
After installing the library, you configure it in the Startup:
public void ConfigureServices(IServiceCollection services)
{
services.AddProblemDetails();
}
public void Configure(IApplicationBuilder app)
{
app.UseProblemDetails();
}
There are several options you can configure. For example, if you want to control when the exception details (the stack trace) are included in the response:
services.AddProblemDetails(options =>
{
options.IncludeExceptionDetails = (ctx, ex) => Environment.IsEnvironment("Local");
});
Here’s what it produces:
{
"type": "https://httpstatuses.com/500",
"title": "Internal Server Error",
"status": 500,
"detail": "cannot_create_suspended_customer",
"errors": [
{
"message": "cannot_create_suspended_customer",
"type": "System.InvalidOperationException",
"raw": "System.InvalidOperationException: cannot_create_suspended_customer\r\n ...."
}
],
"traceId": "00-f4cf69c15ca6be46aa547d2de2294990-4e61f750b2a78643-00"
}
Custom Validation Responses
One last case I’d like to explore is when handling validation errors (that aren’t handled by the ModeState). For example, I may have to perform some calculations and database lookups to confirm if an incoming request is valid.
For this example, let’s assume that the domain validation routines all use a standard ‘error’ response, a dictionary whose keys represent properties or areas of the request and the values are the error(s):
public class DomainErrors : Dictionary<string, string[]>
{
public bool IsValid => Count == 0;
}
public Task<DomainErrors> CanCreateCustomerAsync(CreateCustomerData data, CancellationToken token);
If the dictionary is empty then no errors were discovered.
The goal is to transform this into a standard 400 BadRequest response using the Problem Details format:
{
"type": "https://httpstatuses.com/400",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-a47835ca295cb043bdc00c08c328b194-94b5b8f4fe803945-00",
"errors": {
"customer": [
"customer_already_exists"
]
}
}
My first attempt at getting this type of output was:
if (!errors.IsValid)
{
var pd = new ValidationProblemDetails(errors)
{
Type = "https://httpstatuses.com/400"
};
var traceId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
if (traceId != null)
{
pd.Extensions["traceId"] = traceId;
}
return BadRequest(pd);
}
I worked this into a helper method for better reusability:
if (!errors.IsValid)
{
return errors.ToBadRequest(HttpContext);
}
public static BadRequestObjectResult ToBadRequest(this DomainErrors errors, HttpContext httpContext)
{
var pd = new ValidationProblemDetails(errors)
{
Type = "https://httpstatuses.com/400"
};
var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier;
if (traceId != null)
{
pd.Extensions["traceId"] = traceId;
}
return new BadRequestObjectResult(pd);
}
Summary
Standardizing the ‘problem’ responses frm HTTP APIs is something that makes a lot of sense. The existing framework from ASP.NET Core and some helpful contributions from the community make this a pretty straightforward proposition.