Skip to content

Customizing the validation error response shape #68705

Description

@justindbaur

Background and Motivation

I want to use data annotations validation but instead of returning a response body like:

{
  "title": "One or more validation errors occurred.",
  "errors": {
    "Name": ["The field Name must be a string with a minimum length of 2 and a maximum length of 20."],
    "Email": ["The Email field is not a valid e-mail address."],
    "Age": ["The field Age must be between 18 and 120."]
  }
}

The API also gives enough flexibility to make it pretty easy to localize those error messages but it does not make it easy to make it so you could localize those error messages on the client side, where a lot of single page applications will do their internationalization. I would love for enough API flexibility to return an error like:

{
  "type": "https://problems.example.com/validation-failed",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": [
    {
      "path": "/name",
      "type": "invalid_length",
      "params": { "min": 2, "max": 20 },
      "detail": "The field Name must be a string with a minimum length of 2 and a maximum length of 20."
    },
    {
      "path": "/email",
      "type": "invalid_format",
      "params": { "format": "email" },
      "detail": "The Email field is not a valid e-mail address."
    },
    {
      "path": "/age",
      "code": "invalid_range_value",
      "params": { "min": 18, "max": 120 },
      "detail": "The field Age must be between 18 and 120."
    }
  ]
}

Proposed API

namespace Microsoft.Extensions.Validation;

public sealed class ValidationError
{
+   public ValidationAttribute? ValidationAttribute { get; init; }
}

+public interface IValidationErrorsHandler
+{
+   ValueTask<bool> TryHandleAsync(HttpContext httpContext, IReadOnlyDictionary<string, IReadOnlyList<ValidationError>>, CancellationToken cancellationToken);
+}

Usage Examples

public sealed record CodedError(
      string Path,
      string Type,
      [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
      IReadOnlyDictionary<string, object?>? Params,
      string Detail);

internal sealed class CodedValidationErrorsHandler(IOptions<JsonOptions> jsonOptions) : IValidationErrorsHandler
{
    private readonly JsonNamingPolicy _naming =
        jsonOptions.Value.SerializerOptions.PropertyNamingPolicy ?? JsonNamingPolicy.CamelCase;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        IReadOnlyDictionary<string, IReadOnlyList<ValidationError>> errors,
        CancellationToken cancellationToken)
    {
        var problem = new CodedValidationProblem(
            Type: "https://problems.example.com/validation-failed",
            Title: "One or more validation errors occurred.",
            Status: StatusCodes.Status400BadRequest,
            TraceId: Activity.Current?.Id ?? httpContext.TraceIdentifier,
            Errors: errors
                .OrderBy(entry => entry.Key, StringComparer.Ordinal)
                .SelectMany(entry => entry.Value.Select(Map))
                .ToArray());

        httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
        await httpContext.Response.WriteAsJsonAsync(
            problem, s_options, contentType: "application/problem+json", cancellationToken);
        return true;
    }

    private CodedError Map(ValidationError error)
    {
        var (type, parameters) = Describe(error.ValidationAttribute);
        return new CodedError(ToJsonPointer(error.Path), type, parameters, error.ErrorMessage);
    }

    private static (string Type, IReadOnlyDictionary<string, object?>? Params) Describe(ValidationAttribute? attribute) =>
        attribute switch
        {
            StringLengthAttribute a => ("invalid_length", P(("min", Positive(a.MinimumLength)), ("max", a.MaximumLength))),
            EmailAddressAttribute   => ("invalid_format", P(("format", "email"))),
            RangeAttribute a        => ("invalid_range_value", P(("min", a.Minimum), ("max", a.Maximum))),

            // ... required, invalid_value, mismatch, and the remaining formats

            // null: IValidatableObject, or a source we have no mapping for.
            _ => ("invalid", null)
        };
}

Alternative Designs

If you did just the ValidationAttribute addition to ValidateionError then I could build my own validation endpoint filter based on the source code here and still get the benefits of the source generator. The IValidationErrorsHandler exists just so I can take advantage of 99% ValidationEndpointFilterFactory without rewriting it all.

Risks

The ValidationAttribute property probably can't be made required since its a public shipped type already and so it will be optional but the internal implementation would assign it whenever available.

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-proposalapi-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etc

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions