Published on

Building Endpoint Validation Filters in ASP.NET Core Minimal APIs

10 min read
Authors
  • avatar
    Name
    Ivan Gechev
    Twitter

In the world of ASP.NET Core, Minimal APIs provide us with a fantastic, streamlined approach for building APIs. However, as our applications grow, we are often faced with the challenge of handling repetitive tasks like input validation without bloating our endpoint handlers. This is where endpoint filters come to the rescue.

Let's dive in and see how to leverage endpoint filters to keep our code clean, modular, and robust.

What Are Endpoint Filters and Why Do We Need Them?

Endpoint filters are a powerful feature in ASP.NET Core's Minimal APIs that allow us to execute code either before or after our endpoint logic. We can think of them as a sort of middleware, but specifically aimed at individual endpoints or endpoint groups rather than our entire application pipeline.

The goal of any given endpoint is to execute some core business logic. But in a real world scenario, we need to run many other actions alongside it: logging, authentication, caching, and, of course, validation. These are often called cross-cutting concerns because they apply across many different endpoints.

When we put all this extra logic directly into our endpoint handlers, they become bloated and hard to maintain. In the context of this post, we also end up repeating the same validation code in every POST and PUT method, violating the DRY (Don't Repeat Yourself) principle.

We'll build a simple API that revolves around cats. We'll use FluentValidation to enforce our validation rules and an in-memory database to keep things simple (as not to bore you with trivial details) and focused on the actual filter implementation.

Let's look at our base class first:

Cat.cs
public class Cat
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Breed { get; set; }
    public required int Age { get; set; }
}

Here we have a basic class with nothing too fancy - just an identifier, name, breed, and age.

Once this is done, we can look into our DbContext implementation:

CatDbContext.cs
public class CatDbContext(DbContextOptions<CatDbContext> options) 
    : DbContext(options)
{
    public virtual DbSet<Cat> Cats { get; set; }
}

Again, nothing fancy, just a very basic implementation.

Now, we move to our contract:

CreateCatRequest.cs
public record CreateCatRequest(string Name, string Breed, int Age);

And then, we move to the validator:

CreateCatRequestValidator.cs
public class CreateCatRequestValidator : AbstractValidator<CreateCatRequest>
{
    public CreateCatRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MinimumLength(2)
            .WithMessage("Cat names must be at least 2 characters long");
        
        RuleFor(x => x.Breed)
            .NotEmpty()
            .MinimumLength(3)
            .WithMessage("Breed must be at least 3 characters long");
        
        RuleFor(x => x.Age)
            .GreaterThan(0)
            .LessThan(30)
            .WithMessage("Age must be between 1 and 29 years");
    }
}

Here, we have three simple rules for our main properties. Our string properties must not be empty and meet a certain minimal length. The Age property on the other hand has to be in the range between 1 and 29.

Finally, we can look at our Program class:

Program.cs
using FluentValidation;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<CatDbContext>(options =>
    options.UseInMemoryDatabase("CatsDb"));

builder.Services.AddValidatorsFromAssemblyContaining<Program>();

var app = builder.Build();

app.MapPost("/cats", async (
    CreateCatRequest request,
    IValidator<CreateCatRequest> validator,
    CatDbContext db,
    CancellationToken cancellationToken) =>
{
    var validationResult = await validator.ValidateAsync(request);

    if (!validationResult.IsValid)
    {
        return Results.ValidationProblem(
            validationResult.ToDictionary(),
            statusCode: StatusCodes.Status422UnprocessableEntity);
    }

    var cat = new Cat
    {
        Name = request.Name,
        Breed = request.Breed,
        Age = request.Age
    };
    
    db.Cats.Add(cat);
    await db.SaveChangesAsync(cancellationToken);
    
    return Results.Created($"/cats/{cat.Id}", cat);
})
.Produces(StatusCodes.Status201Created)
.ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity);

app.Run();

As we mentioned previously, we use an in-memory database. We also call the AddValidatorsFromAssemblyContaining<T>() method to register all validators from the assembly that holds our Program class.

Our POST endpoint relies on the IValidator<T> from the FluentValidation library to check whether we have a valid request, and if we don't, to return a 422 Unprocessable Entity status code.

Once this has been taken care of, we initialize a Cat entity based on the request object and save it to the database.

The validation part of this endpoint will have to be repeated throughout our application. If we have a handful of endpoints this will be just fine, and implementing a custom filter might seem like an overkill, but what if we have tens or hundreds of endpoints? Then a filter sounds just like the thing we really need.

Implementing a Reusable Endpoint Validation Filter

Now it's time for some magic:

ValidationFilter.cs
public class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
	public async ValueTask<object?> InvokeAsync(
		EndpointFilterInvocationContext context,
		EndpointFilterDelegate next)
	{
		var argument = context.Arguments.OfType<T>().First();

		var validationResult = await validator.ValidateAsync(
			argument,
			context.HttpContext.RequestAborted);

		if (!validationResult.IsValid)
		{
			return Results.ValidationProblem(
				validationResult.ToDictionary(),
				statusCode: StatusCodes.Status422UnprocessableEntity);
		}

		return await next(context);
	}
}

We start by creating the ValidationFilter<T> class that implements the IEndpointFilter interface. We also use a primary constructor to inject an IValidator<T> instance. This will utilize any FluentValidation validators we have defined in our assembly.

The InvokeAsync() method is where the real magic happens. Here, we start by using context.Arguments.OfType<T>().First() to search through all arguments passed to our endpoint and find the one that matches our type T. In the context of this post, this will be CreateCatRequest.

Next, we call the ValidateAsync() method and pass down our argument and a cancellation token from the HTTP request. By using context.HttpContext.RequestAborted, we ensure that the validation will be canceled if the user aborts the request.

If you are interested in having a deep dive into Minimal APIs, you can check my Minimal APIs in ASP.NET Core course here.

If validation fails, we return 422 Unprocessable Entity status code with all validation errors. This means that the actual endpoint handler will not even be called in this case.

On the other hand, if we have successfully validated the request, we use next(context). This will invoke the actual endpoint handler and allow the request to continue through the pipeline.

To make things even easier, will go a step further:

ValidationFilterExtensions.cs
public static class ValidationFilterExtensions
{
    public static RouteHandlerBuilder AddRequestValidation<T>(
        this RouteHandlerBuilder routeBuilder)
    {
        return routeBuilder
            .AddEndpointFilter<ValidationFilter<T>>()
            .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity);
    }
}

We define the ValidationFilterExtensions class with a single extension method called AddRequestValidation<T>(). The method extends RouteHandlerBuilder, which is what you get when you call MapPost, MapGet, or any other endpoint mapping method.

Our method does two very important things. First, it adds our validation filter to the endpoint using AddEndpointFilter<ValidationFilter<T>>(). Second, it calls ProducesValidationProblem() to properly document that this endpoint can return a 422 Unprocessable Entity status code.

This is useful for OpenAPI/Swagger documentation, allowing API consumers to know what responses to expect. If you want to read more about this, you can check my How to Define Response Types in ASP.NET Core Minimal APIs post.

The beauty of this approach is that it's completely generic and reusable. You want to validate a different request type? Just call AddRequestValidation<YourRequestType>() and you're done. No need to duplicate validation logic across endpoints.

Let's see how all this code works in practice:

Program.cs
app.MapPost("/cats", async (
    CreateCatRequest request,
    CatDbContext db,
    CancellationToken ct) =>
{
    var cat = new Cat
    {
        Name = request.Name,
        Breed = request.Breed,
        Age = request.Age
    };
    
    db.Cats.Add(cat);
    await db.SaveChangesAsync(ct);
    
    return Results.Created($"/cats/{cat.Id}", cat);
})
.AddRequestValidation<CreateCatRequest>()
.Produces(StatusCodes.Status201Created);

That's it! Our validation is now fully wired up. If we send a POST request to the /cats endpoint with an empty name or an invalid age, our filter will intercept it and return a detailed error response before our handler logic even gets the chance to run.

Conclusion

When it comes to building clean and maintainable Minimal APIs, endpoint filters are a vital tool. By extracting any cross-cutting concerns like validation into reusable endpoint filters, we can simplify our endpoint handlers, reduce code duplication, and create more robust applications as a whole. This approach allows us to have full control over our request pipeline while providing a straightforward way to enforce application-wide rules, letting us focus on the business logic that really matters. 🐱