DEV Community

Mohsen Esmailpour
Mohsen Esmailpour

Posted on

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."
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

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."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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>();
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

And final step is registering CustomProblemDetailsFactory in ConfigureServices method of Startup class:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddTransient<ProblemDetailsFactory, CustomProblemDetailsFactory>();
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
...
Enter fullscreen mode Exit fullscreen mode

You can find the source code for this walkthrough on Github.

Discussion (0)