Extending ProblemDetails - Add error code to ValidationProblemDetails
A while back in one project I faced a situation that required returning custom error code from API to the client. In the client app, the user after completing the registration process had to confirm his phone number. For some users, no text message was sent at that time and they could not do the phone verification step. Later, the user after login encountered an unverified phone number error and had to be redirected to the phone number verification page, but it was difficult for the client to understand when to redirect the user to the page because the server only returned an error message.
Since ASP.NET Core 2.2, I followed RFC 7807 specification standard to format errors in web API responses by using ProblemDetails.
The constructor of ValidationProblemDetails
class accepts ModelStateDictionary
and Dictionary<string, string[]>
that the key field is property name and value is a list of error messages and the dictionary serialized into error field:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"User": [
"The user phone number is not verified."
]
}
}
I'm going to change error field to this:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": [
{
"code": 100,
"message": "The user phone number is not verified."
}
]
}
Let's begin by creating custom ValidationProblemDetails
class:
using Microsoft.AspNetCore.Mvc;
public class CustomValidationProblemDetails : ValidationProblemDetails
{
public CustomValidationProblemDetails()
{
}
[JsonPropertyName("errors")]
public new IEnumerable<ValidationError> Errors { get; } = new List<ValidationError>();
}
ValidationProblemDetails
has an Error property that is IDictionary<string, string[]>
and we replace this property with our version to add code error.
public class ValidationError
{
public int Code { get; set; }
public string Message { get; set; }
}
Constructor of ValidationProblemDetails accepts ModelStateDictionary
and we need to convert it to list of ValidationError
:
public CustomValidationProblemDetails(IEnumerable<ValidationError> errors)
{
Errors = errors;
}
public CustomValidationProblemDetails(ModelStateDictionary modelState)
{
Errors = ConvertModelStateErrorsToValidationErrors(modelState);
}
private List<ValidationError> ConvertModelStateErrorsToValidationErrors(ModelStateDictionary modelStateDictionary)
{
List<ValidationError> validationErrors = new();
foreach (var keyModelStatePair in modelStateDictionary)
{
var errors = keyModelStatePair.Value.Errors;
switch (errors.Count)
{
case 0:
continue;
case 1:
validationErrors.Add(new ValidationError { Code = null, Message = errors[0].ErrorMessage });
break;
default:
var errorMessage = string.Join(Environment.NewLine, errors.Select(e => e.ErrorMessage));
validationErrors.Add(new ValidationError { Message = errorMessage });
break;
}
}
return validationErrors;
}
Now it's time to create custom ProblemDetailsFactory
to create CustomValidationProblemDetails
when we want to return bad request response:
public class CustomProblemDetailsFactory : ProblemDetailsFactory
{
public override ProblemDetails CreateProblemDetails(HttpContext httpContext, int? statusCode = null, string title = null,
string type = null, string detail = null, string instance = null)
{
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Type = type,
Detail = detail,
Instance = instance,
};
return problemDetails;
}
public override ValidationProblemDetails CreateValidationProblemDetails(HttpContext httpContext,
ModelStateDictionary modelStateDictionary, int? statusCode = null, string title = null, string type = null,
string detail = null, string instance = null)
{
statusCode ??= 400;
type ??= "https://tools.ietf.org/html/rfc7231#section-6.5.1";
instance ??= httpContext.Request.Path;
var problemDetails = new CustomValidationProblemDetails(modelStateDictionary)
{
Status = statusCode,
Type = type,
Instance = instance
};
if (title != null)
{
// For validation problem details, don't overwrite the default title with null.
problemDetails.Title = title;
}
var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier;
if (traceId != null)
{
problemDetails.Extensions["traceId"] = traceId;
}
return problemDetails;
}
}
And final step is registering CustomProblemDetailsFactory
in ConfigureServices
method of Startup
class:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddTransient<ProblemDetailsFactory, CustomProblemDetailsFactory>();
}
So far we haven't set the Code
property of ValidationError
and it's value will be null for data annotation validation or you can set default value when converting ModelStateDictionary
to list of ValidationError
:
validationErrors.Add(new ValidationError { Code = "100", Message = errors[0].ErrorMessage });
In part three of What Every ASP.NET Core Web API project needs - Exception handling middleware series, I have created a domain exception class and also handling domain exception in ExceptionHandlingMiddleware and convert it to bad request response. I added the Code
property to domain exception class to pass code:
public class DomainException : Exception
{
public DomainException(string message, string code = null)
: base(message)
{
Code = code;
}
public string Code { get; }
}
Back to my scenario, in the login method, I want to check if the phone number of the user is not confirmed throw a domain exception and catch in middleware and return bad request:
public async Task<IActionResult> Login(LoginViewModel model)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (!user?.PhoneConfirmed)
throw new DomainException("The user phone number is not verified.", Code: "120");
}
And finally in exception handling middleware catch exception and convert to CustomValidationProblemDetails
:
public async Task Invoke(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception ex)
{
string result;
if (ex is DomainException e)
{
var problemDetails = new CustomValidationProblemDetails(
new List<ValidationError>
{
new() { Code = e.Code, Message = e.Message }
}
)
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = "One or more validation errors occurred.",
Status = (int)HttpStatusCode.BadRequest,
Instance = context.Request.Path,
};
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
result = JsonSerializer.Serialize(problemDetails);
}
...
You can find the source code for this walkthrough on Github.