Adding Validation to Your API

In the previous course, we used the [Required] attribute to ensure that a property is not null or empty.

public class CreateEmployeeRequest
{
    [Required]
    public string FirstName { get; set; }

    [Required]
    public string LastName { get; set; }
}

The System.ComponentModel.DataAnnotations namespace provides a variety of attributes that we can use to validate our request models.

Attribute Description Example
[Required] Ensures that the property is not null or empty [Required] public string FirstName { get; set; }
[StringLength] Ensures that the property is a string and that its length is within a specified range [StringLength(10, MinimumLength = 3)] public string FirstName { get; set; }
[Range] Ensures that the property is within a specified range [Range(18, 65)] public int Age { get; set; }
[EmailAddress] Ensures that the property is a valid email address [EmailAddress] public string Email { get; set; }
[Phone] Ensures that the property is a valid phone number [Phone] public string Phone { get; set; }
[Url] Ensures that the property is a valid URL [Url] public string Website { get; set; }

How this fits into our API

Let's take a crack at refactoring our POST endpoint to use some of these attributes.

Firstly, let's revisit what happens when we POST to our employees endpoint with an invalid object:

Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "CreateEmployeeRequest employee" from the request body as JSON.
 ---> System.Text.Json.JsonException: JSON deserialization for type 'CreateEmployeeRequest' was missing required properties, including the following: lastName
   at System.Text.Json.ThrowHelper.ThrowJsonException_JsonRequiredPropertyMissing(JsonTypeInfo parent, BitArray requiredPropertiesSet)
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.ContinueDeserialize(ReadBufferState& bufferState, JsonReaderState& jsonReaderState, ReadStack& readStack)
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.DeserializeAsync(Stream utf8Json, CancellationToken cancellationToken)
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.DeserializeAsObjectAsync(Stream utf8Json, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Http.HttpRequestJsonExtensions.ReadFromJsonAsync(HttpRequest request, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Http.HttpRequestJsonExtensions.ReadFromJsonAsync(HttpRequest request, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<HandleRequestBodyAndCompileRequestDelegateForJson>g__TryReadBodyAsync|102_0(HttpContext httpContext, Type bodyType, String parameterTypeName, String parameterName, Boolean allowEmptyRequestBody, Boolean throwOnBadRequest, JsonTypeInfo jsonTypeInfo)
   --- End of inner exception stack trace ---
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.Log.InvalidJsonRequestBody(HttpContext httpContext, String parameterTypeName, String parameterName, Exception exception, Boolean shouldThrow)
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<HandleRequestBodyAndCompileRequestDelegateForJson>g__TryReadBodyAsync|102_0(HttpContext httpContext, Type bodyType, String parameterTypeName, String parameterName, Boolean allowEmptyRequestBody, Boolean throwOnBadRequest, JsonTypeInfo jsonTypeInfo)
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass102_2.<<HandleRequestBodyAndCompileRequestDelegateForJson>b__2>d.MoveNext()
--- End of stack trace from previous location ---
   at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)
   at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

We don't get anything useful back except a nasty stack trace, which won't even be returned to a client in a production environment (for good reason - exposed stack traces are a security risk).

Wouldn't it be nice if we could return structured data to the caller such that they could easily understand what went wrong?

Enter ProblemDetails. This is a standard way to return structured data describing errors from an API. See more here.

Let's add this to our services and see what happens:

builder.Services.AddProblemDetails();

We now get a better structure from the API:

{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "Microsoft.AspNetCore.Http.BadHttpRequestException",
    "status": 400,
    "detail": "Failed to read parameter \"CreateEmployeeRequest employee\" from the request body as JSON.",
    "exception": {
        "details": "Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter \"CreateEmployeeRequest employee\" from the request body as JSON.\n ---> System.Text.Json.JsonException: JSON deserialization for type 'CreateEmployeeRequest' was missing required properties, including the following: lastName\n   at System.Text.Json.ThrowHelper.ThrowJsonException_JsonRequiredPropertyMissing(JsonTypeInfo parent, BitArray requiredPropertiesSet)\n   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)\n   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)\n   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)\n   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.ContinueDeserialize(ReadBufferState& bufferState, JsonReaderState& jsonReaderState, ReadStack& readStack)\n   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.DeserializeAsync(Stream utf8Json, CancellationToken cancellationToken)\n   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.DeserializeAsObjectAsync(Stream utf8Json, CancellationToken cancellationToken)\n   at Microsoft.AspNetCore.Http.HttpRequestJsonExtensions.ReadFromJsonAsync(HttpRequest request, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken)\n   at Microsoft.AspNetCore.Http.HttpRequestJsonExtensions.ReadFromJsonAsync(HttpRequest request, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken)\n   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<HandleRequestBodyAndCompileRequestDelegateForJson>g__TryReadBodyAsync|102_0(HttpContext httpContext, Type bodyType, String parameterTypeName, String parameterName, Boolean allowEmptyRequestBody, Boolean throwOnBadRequest, JsonTypeInfo jsonTypeInfo)\n   --- End of inner exception stack trace ---\n   at Microsoft.AspNetCore.Http.RequestDelegateFactory.Log.InvalidJsonRequestBody(HttpContext httpContext, String parameterTypeName, String parameterName, Exception exception, Boolean shouldThrow)\n   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<HandleRequestBodyAndCompileRequestDelegateForJson>g__TryReadBodyAsync|102_0(HttpContext httpContext, Type bodyType, String parameterTypeName, String parameterName, Boolean allowEmptyRequestBody, Boolean throwOnBadRequest, JsonTypeInfo jsonTypeInfo)\n   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass102_2.<<HandleRequestBodyAndCompileRequestDelegateForJson>b__2>d.MoveNext()\n--- End of stack trace from previous location ---\n   at Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware.Invoke(HttpContext httpContext)\n   at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)\n   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)",
        "headers": {
            "Accept": [
                "*/*"
            ],
            "Connection": [
                "keep-alive"
            ],
            "Host": [
                "localhost:5129"
            ],
            "User-Agent": [
                "PostmanRuntime/7.37.3"
            ],
            "Accept-Encoding": [
                "gzip, deflate, br"
            ],
            "Content-Type": [
                "application/json"
            ],
            "Content-Length": [
                "30"
            ],
            "Postman-Token": [
                "e9e977c7-d22b-436e-a322-8ca77a7f558e"
            ]
        },
        "path": "/employees",
        "endpoint": "HTTP: POST /employees/",
        "routeValues": {}
    }
}

Still, this isn't ideal. We're getting a 400, which is good, but we still have the stack trace in there and no easy way to tell the caller which properties had problems. Not to mention there's a host of other things we don't need.

Let's refactor our code to use the [Required] attribute and see what happens.

public class CreateEmployeeRequest
{
    [Required(AllowEmptyStrings = false)]
    public string? FirstName { get; set; }
    [Required(AllowEmptyStrings = false)]
    public string? LastName { get; set; }
    public string? SocialSecurityNumber { get; set; }

    public string? Address1 { get; set; }
    public string? Address2 { get; set; }
    public string? City { get; set; }
    public string? State { get; set; }
    public string? ZipCode { get; set; }
    public string? PhoneNumber { get; set; }
    public string? Email { get; set; }
}

Wait, what? Why did we change FirstName and LastName to nullable?

Well, we did that because we want to allow the caller to pass in an empty string and let the validation logic handle the invalid case, as opposed to letting the runtime throw an error. This better reflects the "looser" nature of JSON. Remember, we live in an integrated world!

We can then refactor our POST endpoint to handle the validation results and return a 400 with the validation errors.


employeeRoute.MapPost(string.Empty, (CreateEmployeeRequest employeeRequest, IRepository<Employee> repository) => {
    var validationProblems = new List<ValidationResult>();
    var isValid = Validator.TryValidateObject(employeeRequest, new ValidationContext(employeeRequest), validationProblems, true);
    if (!isValid)
    {
        return Results.BadRequest(validationProblems);
    }

    var newEmployee = new Employee {
        FirstName = employeeRequest.FirstName!,
        LastName = employeeRequest.LastName!,
        SocialSecurityNumber = employeeRequest.SocialSecurityNumber,
        Address1 = employeeRequest.Address1,
        Address2 = employeeRequest.Address2,
        City = employeeRequest.City,
        State = employeeRequest.State,
        ZipCode = employeeRequest.ZipCode,
        PhoneNumber = employeeRequest.PhoneNumber,
        Email = employeeRequest.Email
    };
    repository.Create(newEmployee);
    return Results.Created($"/employees/{newEmployee.Id}", employeeRequest);
});

It's better, but it's still not in ProblemDetails format:

[
    {
        "memberNames": [
            "LastName"
        ],
        "errorMessage": "The LastName field is required."
    }
]

If there's one thing I like to do, it's standardize on one type of validation response.

So let's write an extension method to convert our List<ValidationResult> to a ProblemDetails object. (Note that we're using ValidationProblemDetails - it has an Errors dictionary that we can add our validation errors to and is the ASP.NET Core's standard way to return validation errors).

public static class Extensions
{
    public static ValidationProblemDetails ToValidationProblemDetails(this List<ValidationResult> validationResults)
    {
        var problemDetails = new ValidationProblemDetails();

        foreach (var validationResult in validationResults)
        {
            foreach (var memberName in validationResult.MemberNames)
            {
                if (problemDetails.Errors.ContainsKey(memberName))
                {
                    problemDetails.Errors[memberName] = problemDetails.Errors[memberName].Concat([validationResult.ErrorMessage]).ToArray()!;
                }
                else
                {
                    problemDetails.Errors[memberName] = new List<string> { validationResult.ErrorMessage! }.ToArray();
                }
            }
        }

        return problemDetails;
    }
}

Now we can refactor our POST endpoint to use our new extension method:

employeeRoute.MapPost(string.Empty, (CreateEmployeeRequest employeeRequest, IRepository<Employee> repository) => {
    var validationProblems = new List<ValidationResult>();
    var isValid = Validator.TryValidateObject(employeeRequest, new ValidationContext(employeeRequest), validationProblems, true);
    if (!isValid)
    {
        return Results.BadRequest(validationProblems.ToValidationProblemDetails());
    }

    //the rest of the endpoint

It now returns a 400 with the validation errors in the ProblemDetails format:

{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "errors": {
        "LastName": [
            "The LastName field is required."
        ]
    }
}

We can even refactor our tests to ensure that we're getting the correct error messages:

[Fact]
public async Task CreateEmployee_ReturnsBadRequestResult()
{
    // Arrange
    var client = _factory.CreateClient();
    var invalidEmployee = new CreateEmployeeRequest(); // Empty object to trigger validation errors

    // Act
    var response = await client.PostAsJsonAsync("/employees", invalidEmployee);

    // Assert
    Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

    var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
    Assert.NotNull(problemDetails);
    Assert.Contains("FirstName", problemDetails.Errors.Keys);
    Assert.Contains("LastName", problemDetails.Errors.Keys);
    Assert.Contains("The FirstName field is required.", problemDetails.Errors["FirstName"]);
    Assert.Contains("The LastName field is required.", problemDetails.Errors["LastName"]);
}

🌶️🌶️🌶️ In the real world, testing that validation on every property on every request doesn't happen, and is largely a waste of time. Identify the most important properties/behaviors to validate and focus on those first.

This is better, but...

Here's the deal - this is fine, and I'll always say validation is better than no validation. (A long time ago, when I wasn't working for myself, we partnered with a company who DIDN'T validate their endpoints - one request later, we put their system in a totally invalid state, and the app became unusable. Please, validate your requests.)

🌶️🌶️🌶️ That said, I don't like validation attributes and in general, don't use the validation patterns built into ASP.NET Core. Why?

  1. They are a bit inflexible.

  2. Custom validation logic is cumbersome to write. It involves implementing IValidatableObject on the target object and lemme tell you, it just isn't pretty. (Try implementing it yourself - maybe you'll disagree - but I have my way of writing validators and it isn't using the built-in stuff.)

  3. Heck, the validation logic itself is convoluted:

    var validationProblems = new List<ValidationResult>();
    var isValid = Validator.TryValidateObject(employeeRequest, new ValidationContext(employeeRequest), validationProblems, true);
    

    I mean, what is all that stuff even used for? Why do I need a separate validation context? Why do I need to pass that in along with the request? Nevermind, I don't want to know.

  4. This one is a bit pedantic, but I like to separate my request object from my validation object. Unitaskers in programming are the wave!

  5. The validation pipeline for .NET doesn't support async validation. Once you get into deep business rule validation, this becomes pretty important, if only if we're trying to minimize our API surface area and keep with the rule established in the last course: async all the way if you can help it. (We'll demonstrate this later.)

Luckily, there is an excellent validation library called FluentValidation that we can use to handle all of this.

  • FluentValidation allows us to easily create separate validation classes from our request classes.
  • It supports both sync and async validation.
  • It provides a really clean API (truly 'fluent' in every sense of the word).

Let's go ahead and do this with our CreateEmployeeRequest object.

First, let's install the package:

dotnet add package FluentValidation

Or use the UI to do it, like I do.

We'll also install the FluentValidation.DependencyInjectionExtensions package to help us out.

dotnet add package FluentValidation.DependencyInjectionExtensions

I'll also add a CreateEmployeeRequestValidator class to our project, dropping it in the same file as the CreateEmployeeRequest class.

//add the using statement
using FluentValidation;

public class CreateEmployeeRequestValidator : AbstractValidator<CreateEmployeeRequest>
{
    public CreateEmployeeRequestValidator()
    {
        RuleFor(x => x.FirstName).NotEmpty();
        RuleFor(x => x.LastName).NotEmpty();
    }
}

Now we need to register our validator with the IServiceCollection:

using FluentValidation;

builder.Services.AddValidatorsFromAssemblyContaining<Program>();

This allows us to request an IValidator<CreateEmployeeRequest> from the DI container and get it, no problemo.

And now we can refactor our POST endpoint to use our new validator:

employeeRoute.MapPost(string.Empty, async (CreateEmployeeRequest employeeRequest, IRepository<Employee> repository, IValidator<CreateEmployeeRequest> validator) => {
    var validationResults = await validator.ValidateAsync(employeeRequest);
    if (!validationResults.IsValid)
    {
        return Results.ValidationProblem(validationResults.ToDictionary());
    }

    var newEmployee = new Employee {
        FirstName = employeeRequest.FirstName!,
        LastName = employeeRequest.LastName!,
        SocialSecurityNumber = employeeRequest.SocialSecurityNumber,
        Address1 = employeeRequest.Address1,
        Address2 = employeeRequest.Address2,
        City = employeeRequest.City,
        State = employeeRequest.State,
        ZipCode = employeeRequest.ZipCode,
        PhoneNumber = employeeRequest.PhoneNumber,
        Email = employeeRequest.Email
    };
    repository.Create(newEmployee);
    return Results.Created($"/employees/{newEmployee.Id}", employeeRequest);
});

And our test to ensure we're getting the correct error messages:

[Fact]
public async Task CreateEmployee_ReturnsBadRequestResult()
{
    // Arrange
    var client = _factory.CreateClient();
    var invalidEmployee = new CreateEmployeeRequest(); // Empty object to trigger validation errors

    // Act
    var response = await client.PostAsJsonAsync("/employees", invalidEmployee);

    // Assert
    Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

    var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
    Assert.NotNull(problemDetails);
    Assert.Contains("FirstName", problemDetails.Errors.Keys);
    Assert.Contains("LastName", problemDetails.Errors.Keys);
    Assert.Contains("'First Name' must not be empty.", problemDetails.Errors["FirstName"]);
    Assert.Contains("'Last Name' must not be empty.", problemDetails.Errors["LastName"]);
}

Note the use of the async validation method, ValidateAsync. This becomes really important as you refactor your app to add more complex validation behaviors. Say for example you wanted to validate that the Social Security Number was unique. You might rewrite your validator to do something like this:

public class CreateEmployeeRequestValidator : AbstractValidator<CreateEmployeeRequest>
{
    private readonly EmployeeRepository _repository;

    public CreateEmployeeRequestValidator(EmployeeRepository repository)
    {
        this._repository = repository;
        
        RuleFor(x => x.FirstName).NotEmpty();
        RuleFor(x => x.LastName).NotEmpty();
        RuleFor(x => x.SocialSecurityNumber)
            .Cascade(CascadeMode.Stop)
            .NotEmpty().WithMessage("SSN cannot be empty.")
            .MustAsync(BeUnique).WithMessage("SSN must be unique.");
    }

    private async Task<bool> BeUnique(string ssn, CancellationToken token)
    {
        return await _repository.GetEmployeeBySsn(ssn) != null;
    }
}

The example app won't contain this code sample - it's provided for demonstration purposes only. (Note the use of a service class, our Repository - FluentValidation is designed to work with dependency injection.)

Finally, because FluentValidation is purpose-built for validation, it has a bunch of other cool features that the built-in validation attributes don't have. For example, you can nest validation behaviors in a clean(ish) way:

public class CreateEmployeeRequestValidator : AbstractValidator<CreateEmployeeRequest>
{
    private readonly EmployeeRepository _repository;

    public CreateEmployeeRequestValidator(EmployeeRepository repository)
    {
        this._repository = repository;
        
        RuleFor(x => x.FirstName).NotEmpty();
        RuleFor(x => x.LastName).NotEmpty();
        RuleFor(x => x.SocialSecurityNumber).Cascade(CascadeMode.Stop)
            .NotEmpty().WithMessage("SSN cannot be empty.")
            .MustAsync(BeUnique).WithMessage("SSN must be unique.");

        When(r => r.Address1 != null, () => {
            RuleFor(x => x.Address1).NotEmpty();
            RuleFor(x => x.City).NotEmpty();
            RuleFor(x => x.State).NotEmpty();
            RuleFor(x => x.ZipCode).NotEmpty();
        });
    }

    private async Task<bool> BeUnique(string ssn, CancellationToken token)
    {
        return await _repository.GetEmployeeBySsn(ssn) != null;
    }
}