November 14, 2022

Minimal API validation with ASP.NET 7.0 Endpoint Filters

If you are still getting to grips with Minimal APIs I did a how-to on moving from MVC to Minimal APIs here.

In ASP.NET MVC we often use filters to centralise cross-cutting concerns that apply to every request. The initial release of Minimal APIs had no such pipeline. This inevitably resulted in duplicate logic in each endpoint for aspects such as validation:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

var app = builder.Build();

app.MapPost("/customers", (RegisterCustomerRequest customer, IValidator<RegisterCustomerRequest> validator) =>
{
    var validationResult = validator.Validate(customer);

    if (validationResult.IsValid)
    {
        // do the thing
        return Results.Ok();
    }

    return Results.ValidationProblem(validationResult.ToDictionary(),
        statusCode: (int)HttpStatusCode.UnprocessableEntity);
});

public class RegisterCustomerRequest
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }

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

In the above example, if I make a request to POST /customers without the required parameters I receive a response similar to the below:

{
    "type": "https://tools.ietf.org/html/rfc4918#section-11.2",
    "title": "One or more validation errors occurred.",
    "status": 422,
    "errors": {
        "FirstName": [
            "'First Name' must not be empty."
        ],
        "LastName": [
            "'Last Name' must not be empty."
        ]
    }
}

It’s not like the above code is horrendous but given we’re repeating the same logic on every endpoint, it would be nice if it could be encapsulated into some kind of pipeline.

Introducing Endpoint Filters

The great news is that as of ASP.NET 7.0 we have the ability to “opt-in” to a filter pipeline and create custom endpoint filters that work in a similar way to filters in MVC.

We can now improve on the above example by moving our validation logic into a filter:

public class ValidationFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        T? argToValidate = context.GetArgument<T>(0);
        IValidator<T>? validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();

        if (validator is not null)
        {
            var validationResult = await validator.ValidateAsync(argToValidate!);
            if (!validationResult.IsValid)
            {
                return Results.ValidationProblem(validationResult.ToDictionary(),
                    statusCode: (int)HttpStatusCode.UnprocessableEntity);
            }
        }

        // Otherwise invoke the next filter in the pipeline
        return await next.Invoke(context);
    }
}

Note that in this example I’m relying on a convention that my argument to be validated is the first argument in my delegate signature.

We can now simplify the above endpoint by removing the inline validation logic and activating our filter:

app.MapPost("/customers", ([FromBody] RegisterCustomerRequest customer) =>
{
    // do the thing
    return Results.Ok();
})
.AddEndpointFilter<ValidationFilter<RegisterCustomerRequest>>();

This is great but there are two things I would still like to improve on:

  1. We’re having to check the argument types and resolve the appropriate validators on every request
  2. We have to duplicate the AddEndpointFilter for each endpoint

Fortunately both concerns can be addressed with two other features introduced in ASP.NET 7.0. In addition to attaching filters to an endpoint using AddEndpointFilter we can also create an endpoint filter factory that determines, at startup, the filters that should be attached.

For example, with the following factory, we only add the filter to an endpoint if the signature of the endpoint delegate contains the required argument type:

app.MapPost("/customers", ([FromBody] RegisterCustomerRequest customer) =>
{
    // do the thing
    return Results.Ok();
})
.AddEndpointFilterFactory((context, next) =>
{
    if (context.MethodInfo.GetParameters().Any(p => p.ParameterType == typeof(RegisterCustomerRequest)))
    {
        var filter = new ValidationFilter<RegisterCustomerRequest>();
        return invocationContext => filter.InvokeAsync(invocationContext, next);
    }

    // pass-thru filter
    return invocationContext => next(invocationContext);
});

In this example we check the MethodInfo of the endpoint and then attach the validation filter we created in the previous example. Of course this could be generalised so we could support any argument/validator type.

This example still suffers from the fact that we need to call AddEndpointFilterFactory on each endpoint and that’s where we can make use of another feature of ASP.NET 7.0, Route Groups.

From the docs:

The MapGroup extension method helps organize groups of endpoints with a common prefix. It reduces repetitive code and allows for customizing entire groups of endpoints with a single call to methods like RequireAuthorization and WithMetadata which add endpoint metadata.

Particularly relevant to this blog post is that we can attach endpoint filters and factories to a group, for example:

var root = app.MapGroup("");
root.AddEndpointFilter<SomeFilter>();

root.MapGet("/one", () => Results.Ok());
root.MapGet("/two", () => Results.Ok());

Here we add the same filter to all the endpoints created under a group. You can even nest groups and filter execution will cascade accordingly.

Putting these features to use we can refactor our validation logic to automatically validate all endpoints in a route group using Fluent Validation.

 [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class ValidateAttribute : Attribute
{
}

public static class ValidationFilter
{
    public static EndpointFilterDelegate ValidationFilterFactory(EndpointFilterFactoryContext context, EndpointFilterDelegate next)
    {
        IEnumerable<ValidationDescriptor> validationDescriptors = GetValidators(context.MethodInfo, context.ApplicationServices);

        if (validationDescriptors.Any())
        {
            return invocationContext => Validate(validationDescriptors, invocationContext, next);
        }

        // pass-thru
        return invocationContext => next(invocationContext);
    }

    private static async ValueTask<object?> Validate(IEnumerable<ValidationDescriptor> validationDescriptors, EndpointFilterInvocationContext invocationContext, EndpointFilterDelegate next)
    {
        foreach (ValidationDescriptor descriptor in validationDescriptors)
        {
            var argument = invocationContext.Arguments[descriptor.ArgumentIndex];

            if (argument is not null)
            {
                var validationResult = await descriptor.Validator.ValidateAsync(
                    new ValidationContext<object>(argument)
                );

                if (!validationResult.IsValid)
                {
                    return Results.ValidationProblem(validationResult.ToDictionary(),
                        statusCode: (int)HttpStatusCode.UnprocessableEntity);
                }
            }
        }

        return await next.Invoke(invocationContext);
    }

    static IEnumerable<ValidationDescriptor> GetValidators(MethodInfo methodInfo, IServiceProvider serviceProvider)
    {
        ParameterInfo[] parameters = methodInfo.GetParameters();

        for (int i = 0; i < parameters.Length; i++)
        {
            ParameterInfo parameter = parameters[i];

            if (parameter.GetCustomAttribute<ValidateAttribute>() is not null)
            {
                Type validatorType = typeof(IValidator<>).MakeGenericType(parameter.ParameterType);

                // Note that FluentValidation validators needs to be registered as singleton
                IValidator? validator = serviceProvider.GetService(validatorType) as IValidator;

                if (validator is not null)
                {
                    yield return new ValidationDescriptor { ArgumentIndex = i, ArgumentType = parameter.ParameterType, Validator = validator };
                }
            }
        }
    }

    private class ValidationDescriptor
    {
        public required int ArgumentIndex { get; init; }
        public required Type ArgumentType { get; init; }
        public required IValidator Validator { get; init; }
    }
}

The above ValidationFilter class exposes a factory function that can be passed to AddEndpointFilterFactory on a route group.

This factory, invoked per endpoint at startup does the following:

  1. Checks the endpoint delegate and looks for any arguments decorated with the [Validate] attribute
  2. Attempts to resolve the appropriate Fluent Validation validator type e.g. IValidator<RegisterCustomerRequest>
  3. Creates a descriptor that represents the argument type, its position and resolved validator
  4. If any descriptors are returned, register an endpoint filter delegate that validates the endpoint arguments using the appropriate validators

The rest of our API can now be reduced to the following:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Singleton);

var root = app.MapGroup("");
root.AddEndpointFilterFactory(ValidationFilter.ValidationFilterFactory);

root.MapPost("/customers", ([Validate] RegisterCustomerRequest customer) =>
{
    // do the thing
    return Results.Ok();
});

app.Run();

public class RegisterCustomerRequest
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }

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

Note the following call has been updated to register the validators as singleton:

builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Singleton);

This is necessary since they’re being resolved at startup. If any of your validators need to be scoped per request, you can adapt the above code to instead return the validator type in ValidationDescriptor and resolve the validator within the EndpointFilterDelegate. You’ll also need to find a way to check for the existence of the validator registration to make this as performant as possible.

Wrap up

In this post we combined the Endpoint Filter, Endpoint Filter Factory and Route Group features introduced in ASP.NET 7.0 to automatically validate requests to endpoints created with Minimal APIs using Fluent Validation.

© 2022 Ben Foster