- Published on
Building Endpoint Validation Filters in ASP.NET Core Minimal APIs
- Authors
- Name
- Ivan Gechev
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.
Our Cat-Related API Before Implementing an Endpoint Validation Filter
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:
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:
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:
public record CreateCatRequest(string Name, string Breed, int Age);
And then, we move to the validator:
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:
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:
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.
here.
If you are interested in having a deep dive into Minimal APIs, you can check my Minimal APIs in ASP.NET Core course
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:
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:
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. 🐱