Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Inserting middleware between UseRouting() and UseEndpoints() as a library author - Part 2

$
0
0
Inserting middleware between UseRouting() and UseEndpoints() as a library author - Part 2

This post follows on from my previous post - if you haven't already, I strongly recommend reading that one first.

These posts are in response to a question from a reader about how library authors can ensure consumers of their library insert the library's middleware at the right point in their app middleware pipeline. In the previous post I showed an approach that allowed you to ensure your middleware runs before a given (named) piece of middleware, for example just before the EndpointMiddleware added via a call to UseEndpoints().

One of the limitations of this approach is that you can only run your middleware before a given named middleware. There's no way to run your middleware after a named middleware. For example, maybe I want my middleware to run immediately after the call to UseRouting().

In my approach from the previous post, there's no way to achieve that - you can only select a middleware to run before. In this post I adapt the approach to allow you to specify which middleware you want to run after. I warn you now though, it's not very pretty…

Kamushek pointed out a significant flaw in the approach taken in these posts - you can only have a single library use this approach to add middleware. That's a shame, but I'm not really sure I would recommend using these approaches anyway: they're more of an intellectual exercise!

The middleware pipeline is built in reverse

In the previous post, I used the RequestDelegate passed in to our custom middleware's constructor to discover what the next middleware is. The middleware pipeline is built up is by creating an instance of middleware, and obtaining a reference to it's Invoke method. This reference is used to create the RequestDelegate for the previous middleware. So each middleware has a reference to the next middleware in the pipeline.

That means the pipeline is built from the end first, working back to the start of the pipeline. Each earlier middleware "wraps" the rest of the pipeline.

Image of how middleware is constructed

This is a problem for us when we want to add middleware after a given named middleware like the EndpointRoutingMiddleware. When the pipeline is being built, there's no way to know what the "previous" middleware in the pipeline will be - we're building the pipeline back-to-front, so it hasn't been created yet!

Of course at runtime the opposite is true. Middleware early in the pipeline can send signals to later middleware, for example by setting values in the HttpContext.Items collection, or via a service in the DI container.

In order to insert middleware at a specific point in your pipeline, we could use these two facts in combination, as I show in the next section.

When in doubt, add moar middleware

I've described two features of the middleware pipeline that we can use to achieve our goal:

  • At build time (and at runtime) a given middleware can inspect the type of the next middleware/RequestDelegate that will run
  • At runtime, middleware can send messages to later middleware

The approach we'll use is:

  • When explicitly adding middleware to the pipeline (in Startup.Configure), intercept the call, and add two extra piece of middleware - one before the target middleware (NameCheckMiddleware), and one after (ConditionalMiddleware). This is similar to the AnalysisMiddleware approach I showed in the previous post.
  • Record the name of the "wrapped" middleware at build time
  • At runtime, pass the name of the wrapped middleware from the "pre-" middleware to the "-post" middleware
  • If the name indicates it's the middleware we're looking for, run the extra functionality.

That's all a bit confusing - hopefully the example below makes more sense. In this example we want to run our middleware immediately after the call to UseRouting().

Name Check and Conditional middleware wraps all the other middleware, but the conditional middleware only executes its logic in one location

Like I said at the start, it's not pretty. But it does work…

Implementing the middleware

Hopefully you have a grasp of what we're trying to achieve here, so lets show some code. There's not really many additional concepts from last time, but I'll provide all the necessary code for completeness.

We'll start with the NameCheckMiddleware. This is the "pre-" middleware that records the name of the middleware being wrapped:

internal class NameCheckerMiddleware
{
    private readonly RequestDelegate _next;
    private readonly string _wrappedMiddlewareName;

    public NameCheckerMiddleware(RequestDelegate next)
    {
        // Store the name of the next middleware once at build time
        _wrappedMiddlewareName = next.Target.GetType().FullName;
        _next = next;
    }

    public Task Invoke(HttpContext httpContext)
    {
        // (Over)write the name of the wrapped middleware, for use by subsequent middleware
        httpContext.Items["WrappedMiddlewareName"] = _wrappedMiddlewareName;

        return _next(httpContext);
    }
}

The name of the RequestDelegate is calculated in the constructor, i.e. only once, when the middleware is constructed. In the Invoke method we set the middleware in the HttpContext.Items dictionary, for use by our "post-" middleware, the ConditionalMiddleware shown below:

internal class ConditionalMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ConditionalMiddleware> _logger;
    private readonly string _runAfterMiddlewareTypeName;

    public ConditionalMiddleware(RequestDelegate next, ILogger<ConditionalMiddleware> logger, string runAfterMiddlewareName)
    {
        // Middleware we're looking for provided as constructor argument
        _runAfterMiddlewareTypeName = runAfterMiddlewareName;
        _next = next;
        _logger = logger;
    }

    public async Task Invoke(HttpContext httpContext)
    {
        // Check if we're running after the middleware we want
        if(IsCorrectMiddleware(httpContext, _runAfterMiddlewareTypeName))
        {
            // If so, run the extra code - just logging as an example here
            _logger.LogInformation("Running conditional middleware after {PreviousMiddleware}", _runAfterMiddlewareTypeName);
        }

        // Either way, run the rest of the pipeline
        await _next(httpContext);
    }


    // Try and get the key added by the NameCheckerMiddleware and see if it's the one we need 
    static bool IsCorrectMiddleware(HttpContext httpContext, string requiredMiddleware)
    {
        return httpContext.Items.TryGetValue("WrappedMiddlewareName", out var wrappedMiddlewareName)
            && wrappedMiddlewareName is string name 
            && string.Equals(name, requiredMiddleware, StringComparison.Ordinal);
    }
}

The difference between this ConditionalMiddleware and the version from my previous post is that we don't know at the middleware build-time whether a given ConditionalMiddleware instance is the one we need. Instead, we have to check the HttpContext.Items dictionary to see what the previous middleware was. If it's the one we're looking for, then we run the extra code.

To add these middleware to the pipeline we provide a custom IApplicationBuilder, just as we did in the last post. The code here is almost identical; the only difference is we're adding both the ConditionalMidleware and the NameCheckerMiddleware in the Use() method:

internal class ConditionalMiddlewareBuilder : IApplicationBuilder
{
    // The middleware we're looking for is provided as a constructor argument
    private readonly string _runAfter;
    public ConditionalMiddlewareBuilder(IApplicationBuilder inner, string runAfter)
    {
        _runAfter = runAfter;
        InnerBuilder = inner;
    }

    private IApplicationBuilder InnerBuilder { get; }

    public IServiceProvider ApplicationServices
    {
        get => InnerBuilder.ApplicationServices;
        set => InnerBuilder.ApplicationServices = value;
    }

    public IDictionary<string, object> Properties => InnerBuilder.Properties;
    public IFeatureCollection ServerFeatures => InnerBuilder.ServerFeatures;
    public RequestDelegate Build() => InnerBuilder.Build();
    public IApplicationBuilder New() => throw new NotImplementedException();

    public IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware)
    {
        // Wrap the provided middleware with a name checker and conditional middleware
        return InnerBuilder
            .UseMiddleware<NameCheckerMiddleware>()
            .Use(middleware)
            .UseMiddleware<ConditionalMiddleware>(_runAfter);
    }
}

In order to use the custom builder, we can use the same IStartupFilter as in the last post:

internal class ConditionalMiddlewareStartupFilter : IStartupFilter
{
    private readonly string _runAfterMiddlewareName;

    public ConditionalMiddlewareStartupFilter(string runAfterMiddlewareName)
    {
        _runAfterMiddlewareName = runAfterMiddlewareName;
    }

    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
    {
        return builder =>
        {
            var wrappedBuilder = new ConditionalMiddlewareBuilder(builder, _runAfterMiddlewareName);
            next(wrappedBuilder);
        };
    }
}

Finally, a couple of extension methods to make adding the startup filter easy:

public static class ConditionalMiddlewareExtensions
{
    public static IServiceCollection AddConditionalMiddleware(this IServiceCollection services, string afterMiddleware)
    {
        return services.AddTransient<IStartupFilter>(_ => new ConditionalMiddlewareStartupFilter(afterMiddleware));
    }

    public static IServiceCollection AddConditionalMiddlewareAfterRouting(this IServiceCollection services)
    {
        return services.AddConditionalMiddleware("Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware");
    }
}

The AddConditionalMiddlewareAfterRouting() method looks for the EndpointRoutingMiddleware added by the UseRouting() call. All that remains is to call the method in our app:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRazorPages();
        services.AddConditionalMiddlewareAfterRouting(); // <-- Add this line
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseDeveloperExceptionPage();
        app.UseHttpsRedirection();
        app.UseStaticFiles();

        app.UseRouting();
        // <-- The ConditionalMiddleware will execute here

        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapRazorPages();
        });
    }
}

Just as in the previous post, we only had to add one line, even though there's a lot going on behind the scenes.

Again, this example is just a proof of concept - it doesn't do anything interesting other than print the following log message just after the EndpointRoutingMiddleware:

info: MyTestApp.ConditionalMiddleware[0]
      Running conditional middleware after Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware

The approach in this post will have slightly more impact at runtime than the approach in the previous post. While we can do the middleware name check up front at pipeline build time, we need to pass this name through the HttpContext.Items dictionary and add a check for every ConditionalMiddleware instance. This is obviously a pretty small penalty in the grand scheme of things, but it's still something I'd be hesitant forcing on consumers of my library.

As in my last post, as highlighted by Kamushek, this technique only works once - you can't add multiple middleware in this way, as they will conflict with each other. That makes the approach of very limited usefulness in my book, but you never know!

Summary

In this post I expanded on the problem posed in my previous post: how can a library author insert middleware at a specific point in a consuming app's middleware pipeline. In the previous post I discussed the approach when you need to place middleware before a specific target middleware. In this post I discuss the slightly trickier task of placing middleware after a target middleware.

The solution in this post requires wrapping each standard piece of middleware in two additional middleware instances. The first, NameCheckerMiddleware records the name of the wrapped middleware, and writes it to the HttpContext.Items dictionary. The subsequent ConditionalMiddleware checks this value to see if the target middleware (e.g. (EndpointRoutingMiddleware) was just executed, and if it was it executes its logic. The end result is definitely not pretty, but it works!


Viewing all articles
Browse latest Browse all 743

Trending Articles