Returning ProblemDetails In ASP.NET
Continuing my research from last week Using ProblemDetails in ASP.NET, I want to include ProblemDetails in other scenarios. For example, when a user receives a 401 or 403 response, there is no payload included in the response. I would prefer to include a ProblemDetails payload, making it simpler for callers to use the API.
Use Status Code Pages
To have ASP.NET return ProblemDetails for 401 and 403 responses, include the Status Code Pages in the request pipeline:
var app = builder.Build();
app.UseStatusCodePages();
app.UseExceptionHandler();
More Details Please
When you are using the API locally, you may wish to have it return more details for the 500 Internal Server Error responses. Again, it is very simple to do, using the Developer Exception Page:
var app = builder.Build();
app.UseStatusCodePages();
if (app.Environment.IsEnvironment("Local"))
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler();
}
Further Customizations
Both the Status Code Pages and Exception Handler allow you to customize the responses even further.
If you do not include the AddProgramDetails()
when building your application, the app.UseStatusCodePages()
will use text/plain
for the response payload. So you may want to change how it behaves.
For the Status Code Pages, you can add your customizations using a custom handler:
app.UseStatusCodePages(handler =>
{
var statusCode = handler.HttpContext.Response.StatusCode;
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = ReasonPhrases.GetReasonPhrase(statusCode),
Detail = "A problem occurred while processing your request."
};
return Results.Problem(problemDetails).ExecuteAsync(handler.HttpContext);
});
But I would not recommend this, instead I would build the application with the AddProgramDetails()
included. By default, the call to app.UseStatusCodePages()
uses the configured
IProblemDetailsService
to produce the response payload. It will include all your ProblemDetails
customizations automatically.
And for the Exception Handler, you can customize it too:
app.UseExceptionHandler(new ExceptionHandlerOptions
{
ExceptionHandler = async context =>
{
var statusCode = context.Response.StatusCode;
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = ReasonPhrases.GetReasonPhrase(statusCode),
Detail = "A problem occurred while processing your request"
};
await Results.Problem(problemDetails).ExecuteAsync(context);
}
});
And similarly to the app.UseStatusCodePages()
, the app.UseExceptionHandler()
will, by default
use the the configured IProblemDetailsService
to produce the response payload. If all you want is
to control the way ProblemDetails are rendered for exceptions, it is best to include those customizations
in the ProblemDetails configuration, and leave the app.UseExceptionHandler()
with the default
settings.
Model Binding with JSON Property Names
A controller that is decorated with the ApiController
attribute will automatically validate the
request payload and will return a 400 Bad Request when it encounters an invalid payload:
public class CreateWidgetRequest
{
[Required]
[JsonPropertyName("widgetName")]
public string Name { get; init; } = null!;
}
By default, if a model validation error occurs, the response will not use the JsonPropertyName
value:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"detail": "Please provide the 'traceid' when contacting us.",
"errors": {
"Name": [
"The Name field is required."
]
},
"traceId": "00-26908c4b78f7284d82643cfd8c1b75d8-5f0ff53c435fa2eb-00"
}
But you can change the configuration to use the JsonPropertyName
attributes:
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
...
builder.Services.AddControllers(configure =>
{
configure.ModelMetadataDetailsProviders.Add(new SystemTextJsonValidationMetadataProvider());
});
Now the expected property name is used:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"detail": "Please provide the 'traceid' when contacting us.",
"errors": {
"widgetName": [
"The widgetName field is required."
]
},
"traceId": "00-a00c672664dbb4d0531a6ca86d68b0d3-b73456d919873338-00"
}
Manual Validation Errors
In action controllers you can return a ValidationProblem
which will construct a ProblemDetails
payload for you. One way is to pass in a ValidationProblemDetails
object:
if (request.Name.Contains("a bad word"))
{
var errors = new Dictionary<string, string[]>
{
{ "widgetName", ["widgetName cannot contain a bad word"] }
};
var problemDetails = new ValidationProblemDetails(errors);
return ValidationProblem(problemDetails);
}
Unfortunately, this overload of the ValidationProblem
method does not use the configured
IProblemDetailsService
. The result is inconsistent with the other ProblemDetails:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"widgetName": [
"widgetName cannot contain a bad word"
]
}
}
However, there is another overload, that use the ModelStateDictionary
that does use the
configured IProblemDetailsService
:
if (request.Name.Contains("a bad word"))
{
ModelState.AddModelError("widgetName", "widgetName cannot contain a bad word");
return ValidationProblem(ModelState);
}
Now we receive the expected payload, with all our customizations to the ProblemDetails:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"detail": "Please provide the 'traceid' when contacting us.",
"errors": {
"widgetName": [
"widgetName cannot contain a bad word"
]
},
"traceId": "00-2b37877913ecb5cd4abb892f84be2ec5-ed4488781e027c6b-00"
}
Conflict Has To Be Different
When using the built in Conflict
response methods, you would expect to get something similar to
the ValidationProblem response. But you’d be wrong. For example:
if (_widgetService.GetWidgets().Any(x => x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)))
{
ModelState.AddModelError("widgetName", "A different widget is already using this name.");
return Conflict(ModelState);
}
I was not expecting this as the output:
{
"widgetName": [
"A different widget is already using this name."
]
}
Well, maybe we can do wrap the ModelState
in a ValidationProblemDetails
?
if (_widgetService.GetWidgets().Any(x => x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)))
{
ModelState.AddModelError("widgetName", "A different widget is already using this name.");
return Conflict(new ValidationProblemDetails(ModelState));
}
Better, but not exactly what we wanted:
{
"title": "One or more validation errors occurred.",
"status": 409,
"errors": {
"widgetName": [
"A different widget is already using this name."
]
}
}
To use our customized ProblemDetails, we can reuse the ValidationProblem
method:
if (_widgetService.GetWidgets().Any(x => x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)))
{
ModelState.AddModelError("widgetName", "A different widget is already using this name.");
return ValidationProblem(statusCode: StatusCodes.Status409Conflict, modelStateDictionary: ModelState);
}
And now we get the desired result:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "One or more validation errors occurred.",
"status": 409,
"detail": "Please provide the 'traceid' when contacting us.",
"errors": {
"widgetName": [
"A different widget is already using this name."
]
},
"traceId": "00-3210edae71164b971bde2c29afd247f3-6b04a93acdecfca6-00"
}
You can use other status codes with the ValidationProblem
besides 409, like 422 Unprocessable Entity:
if (_widgetService.GetWidgets().Any(x => x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)))
{
ModelState.AddModelError("widgetName", "A different widget is already using this name.");
return ValidationProblem(statusCode: StatusCodes.Status422UnprocessableEntity, modelStateDictionary: ModelState);
}
Summary
Use the default UseStatusCodePages
and UseExceptionHandler
middleware to ensure your Web API
uses your customized ProblemDetails payloads for all 400-level and 500 Internal Server Error
responses:
builder.Services.AddProblemDetails(options =>
options.CustomizeProblemDetails = (context) =>
{
// All the customizations in one place
ApplyCustomizations(context.ProblemDetails);
});
...
var app = builder.Build();
// Use the default middleware
app.UseStatusCodePages();
if (app.Environment.IsEnvironment("Local"))
{
// Return more error details locally
app.UseDeveloperExceptionPage();
}
else
{
// Use the default middleware
app.UseExceptionHandler();
}
Use the ModelState
and ValidationProblem
members of the ControllerBase
class to return
ProblemDetails responses:
ModelState.AddModelError("widgetName", "widgetName cannot contain a bad word");
return ValidationProblem(ModelState);
And override the status code if you need to:
ModelState.AddModelError("widgetName", "A different widget is already using this name.");
return ValidationProblem(statusCode: StatusCodes.Status409Conflict, modelStateDictionary: ModelState);
This additional work makes using the API much simpler since the ‘problem’ responses follow a consistent format. Your users will thank you.