Reviewing middleware real quick like
Remember that middleware are the things that handle HTTP requests and responses in ASP.NET Core.
Source: ASP.NET Docs
And remember how we added our own middleware to the pipeline to show how they behaved?
app.Use(async (HttpContext context, RequestDelegate next) =>
{
context.Response.Headers.Append("Content-Type", "text/html");
await context.Response.WriteAsync("<h1>Welcome to Ahoy!</h1>");
});
Try moving that around to different places and see how it affects your requests. For instance, if you move them after your UseControllers()
call, if you make a valid call to a controller endpoint, the middleware we added will not be executed.
These middleware are executed in the order they are added to the pipeline, which makes order extremely important.
Take for instance the middleware chain typically used in ASP.NET Core web apps (documented here):
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.MapDefaultControllerRoute();
Does Spencer ever write his own middleware?
Not often, but I have definitely done it - mainly to handle weird things like multi-tenant SaaS products where each tenant is marked with a different subdomain. It would be useful to have a middleware that can read that subdomain and set the tenant for the remainder of the request for easy access:
app.Use(async (HttpContext context, RequestDelegate next) =>
{
var tenant = context.Request.Host.Host.Split('.')[0];
var tenantId = await _tenantService.GetTenantIdBySubdomain(tenant);
context.Items["TenantId"] = tenantId;
await next(context);
});
For the purposes of this course, it's important to know what middleware is and what it's used for, but we'll mostly be working with the default ones provided by ASP.NET Core.
Filters
Filters are a way to modify the behavior of requests in ASP.NET Core, but only after the request has been routed to a controller method (controller methods are also referred to commonly as actions).
This has one big advantage: filters engage after the route has been matched and model binding has occured.
Think of it as a micro middleware that's invoked after all of the main middleware run (and the route has been matched to something that can actually use the filter).
You can apply a filter globally, or on a more specific set of controllers or actions.
There are several different types of filters:
Filter Type | When it's executed | Where it's defined |
---|---|---|
Authorization filters | Before the action is invoked | Global or per controller |
Resource filters | Before and after the action is invoked | Global, per controller, or per action |
Action filters | Before and after the action is invoked | Global, per controller, or per action |
Exception filters | When an exception is thrown | Global or per controller |
Result filters | After the action is invoked | Global, per controller, or per action |
Some example of common filters built into ASP.NET Core:
[Authorize]
- Checks for valid auth tokens[EnableCors]
- Enables CORS for a specific controller or action[ResponseCache]
- Adds response caching to a controller or action[RequireHttps]
- Redirects to HTTPS if the request is not HTTPS[ValidateAntiForgeryToken]
- Validates anti-forgery tokens for POST requests
Let's write our own filter
While we're at it, let's do ourselves a favor and add a custom filter that addresses this piece of boilerplate:
var validationResults = await ValidateAsync(employeeRequest);
if (!validationResults.IsValid)
{
return ValidationProblem(validationResults.ToModelStateDictionary());
}
You'd think this is built into the framework, but it's not... not exactly anyways. Remember that ApiController
attribute we've been using? It actually is supposed to trigger validation behavior, but that doesn't work well with FluentValidation for one major reason: async validation, which isn't supported by ASP.NET Core. (See the GitHub issue here for more information. Fun fact, this issue was actually filed by the creator of FluentValidation!)
The validator pipeline only supports synchronous validation, and because we live in an async world with validators that can be async, we need to validate the request model in a custom filter.
Let's do that now.
First, let's create a new filter attribute:
public class FluentValidationFilter : IAsyncActionFilter
{
private readonly IServiceProvider _serviceProvider;
private readonly ProblemDetailsFactory _problemDetailsFactory;
public FluentValidationFilter(IServiceProvider serviceProvider, ProblemDetailsFactory problemDetailsFactory)
{
_serviceProvider = serviceProvider;
_problemDetailsFactory = problemDetailsFactory;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
foreach (var parameter in context.ActionDescriptor.Parameters)
{
if (context.ActionArguments.TryGetValue(parameter.Name, out var argumentValue) && argumentValue != null)
{
var argumentType = argumentValue.GetType();
var validatorType = typeof(IValidator<>).MakeGenericType(argumentType);
var validator = _serviceProvider.GetService(validatorType) as IValidator;
if (validator != null)
{
// Validate the argument
ValidationResult validationResult = await validator.ValidateAsync(new ValidationContext<object>(argumentValue));
if (!validationResult.IsValid)
{
validationResult.AddToModelState(context.ModelState);
var problemDetails =
_problemDetailsFactory.CreateValidationProblemDetails(context.HttpContext,
context.ModelState);
context.Result = new BadRequestObjectResult(problemDetails);
return;
}
}
}
}
await next();
}
public void OnActionExecuted(ActionExecutedContext context) { }
}
Holy heck, that thing is BEEFY. What's it doing?
- It implements
IAsyncActionFilter
, which is the interface for async action filters. (There is a sync one as well, but we need async so we're using the async one.) - It gets each of the parameters of the controller action from
context.ActionDescriptor.Parameters
. (That's why we're using filters for this, not middleware - middleware is NOT aware of the controller action or the parameters.) - It uses DI -
IServiceProvider
- to get theIValidator
for the current action's parameter type. - It runs validation and adds the results to the
ModelState
if the validation fails. - It creates a
ValidationProblemDetails
object and sets theModelState
to it if the validation fails. - It sets the
Result
to aBadRequestObjectResult
with theValidationProblemDetails
object if the validation fails.
The weirdest part for me is the ProblemDetailsFactory
tbh - for some reason that is a separately registered component in ASP.NET Core.
(If you're really curious about this filter and what the heck it's doing, I'd set a breakpoint and step through the code with the debugger to see what's happening.)
We can apply this filter globally by adding it to the FilterCollection
like so (in our Program.cs file, replace the AddControllers()
call with this):
builder.Services.AddControllers(options =>
{
options.Filters.Add<FluentValidationFilter>();
});
We can now delete the ValidateAsync
call from our controller actions (and delete the method from BaseController
too. I know, BaseController
feels pretty lonely right now, but we'll use him again soon!)
Run your unit tests and see everything still works.
🌶️🌶️🌶️ While you're free to use the above class for your own code, I wouldn't recommend it as it's pretty simplistic. There is a NuGet package called FluentValidation.AutoValidation that does the same thing except it's much more comprehensive: https://github.com/SharpGrip/FluentValidation.AutoValidation
Refactoring our PUT
endpoint
Now that we have our filter, refactoring and adding a test for our PUT
endpoint is pretty easy - all we have to do is define a validator and add a test, and we're good to go!
Let's show a slightly more advanced validator that doesn't allow us to null out the Address
field if one is already defined in the database.
There's actually a few things we need to do here:
- We need a way to pass our ID from the route to the validator.
- We need to make sure that our validator can access the database to check for the existence of an address.
To do this, we can use the ActionExecutingContext
to get the route values and pass them to our validator.
Here's what that looks like:
public class UpdateEmployeeRequestValidator : AbstractValidator<UpdateEmployeeRequest>
{
private readonly HttpContext _httpContext;
private readonly IRepository<Employee> _repository;
public UpdateEmployeeRequestValidator(IHttpContextAccessor httpContextAccessor, IRepository<Employee> repository)
{
this._httpContext = httpContextAccessor.HttpContext!;
this._repository = repository;
RuleFor(x => x.Address1).MustAsync(NotBeEmptyIfItIsSetOnEmployeeAlreadyAsync).WithMessage("Address1 must not be empty.");
}
private async Task<bool> NotBeEmptyIfItIsSetOnEmployeeAlreadyAsync(string? address, CancellationToken token)
{
await Task.CompletedTask; //again, we'll not make this async for now!
var id = Convert.ToInt32(_httpContext.Request.RouteValues["id"]);
var employee = _repository.GetById(id);
if (employee!.Address1 != null && string.IsNullOrWhiteSpace(address))
{
return false;
}
return true;
}
}
What the heck is all this stuff?
IHttpContextAccessor
is used to get the currentHttpContext
, which contains the route value of the employee we're updating.IRepository<Employee>
is our generic repository, which we can use to get the employee from the database.Convert.ToInt32(_httpContext.Request.RouteValues["id"])
gets theid
from the route.- The
MustAsync
method is used to run our validation logic asynchronously. It's a way to run some custom logic outside of the normal "value is empty" stuff that we normally do.
This class is automatically registered by FluentValidation, so we don't need to do anything special to wire it up, except to add HttpContextAccessor
to our Program.cs
file:
builder.Services.AddHttpContextAccessor();
Finally, we'll update our tests to pass:
[Fact]
public async Task UpdateEmployee_ReturnsOkResult()
{
var client = _factory.CreateClient();
var response = await client.PutAsJsonAsync("/employees/1", new Employee { FirstName = "John", LastName = "Doe", Address1 = "123 Main St" });
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task UpdateEmployee_ReturnsBadRequestWhenAddress()
{
// Arrange
var client = _factory.CreateClient();
var invalidEmployee = new UpdateEmployeeRequest(); // Empty object to trigger validation errors
// Act
var response = await client.PutAsJsonAsync($"/employees/{_employeeId}", invalidEmployee);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.NotNull(problemDetails);
Assert.Contains("Address1", problemDetails.Errors.Keys);
}