Disabling endpoints on different environments (1/2: ASP pipeline)

Disabling endpoints on different environments (1/2: ASP pipeline)

Let's learn how to efficiently manipulate ASP.Net endpoints accessibility on different environments or based on any other more complex logic.

TLDR;

Mark endpoints you wish to manipulate with a custom attribute. Replace the original EndpointDataSources in the ASP pipeline with a new implementation, which filters out marked endpoints.

Scenario

Let's say you have a new shiny v2 endpoint with a different, of course, better, faster, smarter, implementation than v1. It's tested and ready to replace the old one. However, there is an external dependency on another service, which is not yet released to production. Hence you can not release yours either, still, it is already present in the code and used in testing environments in other apps. Removing it would break too many new functionalities.

💡
This can happen in fast-paced development environments built upon multi-team and microservice architecture.

So how do you hide new endpoints from the outside world and still keep them in other environments?

That is the issue ASP.net has not provided a simple built-in solution. But there is a way to manipulate endpoints accessibility without making changes to them, and it is not that hard. Keep reading.

📖
There are a few solutions but they do not meet all the criteria. I will describe them briefly in the next section.

Acceptance criteria

  • Specific endpoints should be accessible only in selected environments.

  • They should also not be present in the API documentation for other environments.

  • Their implementation must not change.

I like to spicy things a little bit and take a bit broader look. So to the three points from above, I would add two more:

  • The developer should be able to declare multiple environments for each endpoint.

  • Calling unavailable endpoints should return immediately without engaging the request pipeline and server resources.

The first one emphasizes flexibility and ease of use of the solution. The second takes into account more non-functional matters like performance and resources.

Anything that works now?

There are a couple of options that the framework provides now for similar cases:

  1. Using IHostEnvirnmment service in controller action directly - This can be tedious as it requires manually changing each action.

  2. IgnoreRoute method used when mapping specific endpoints - Good if we could group routes into some patterns. Can be problematic if we want to do it more granularly.

  3. Own IActionFilter implementation reading configuration at runtime and setting NotFound result for the action. - Works fine, and can be applied on a granular level, but requires ASP to run through a bunch of middlewares and other filters before executing. Maybe the path can be shortened.

  4. Using [ApiExplorerSettings(IgnoreApi = true)] attribute to hide controller from API discovery mechanism. - Removes endpoint only from documentation, it can still be called.

I tried all of them briefly, to see if maybe there is no point in reinventing the wheel. They all however either lack the flexibility to define specific environments for specific endpoints or only 'fake' the execution flow by setting arbitrary result for action, yet still starting all pipeline for it or not even preventing real execution in some cases.

They do not satisfy me.

Removing endpoints from the ASP pipeline

ASP framework is built using pipe and filter architecture. Bunch of middlewares, filters and intermediary services, binding, authorizing, pre and post-processing every request. There must be a place in the flow, where we can jump in and make it work for our benefit.

And we are gonna find it.

Let the default Weather Forecast web API project serve as a sample for this case. We will add a v2 version of it with the same implementation but a different route. And try to remove it later.

Swagger weather forecast v1 and v2 endpoints

How it is done now

From what I have read and checked in the documentation and other blogs, there is no simple out-of-the-box way to hide endpoints the way we want. But this is not the end, but rather the start of this journey. We need to delve deep into the way the ASP framework works to see what we can do about it.

Good that it's all open-source now.

To achieve the requirements we first need to answer two questions:

  1. Where does the framework take information about controllers and endpoints from? And if it is possible to change this behavior?

  2. How do we know what environment are we on? And where to take this information from?

Where does the framework take the endpoint from?

Reading those endpoints must be done somewhere in the setup. Checking the code of the default ASP.net 7 VS project, we can spot one suspicious line just before the run. God bless the meaningful naming.

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
var app = builder.Build();
// other setup 'UseXyz' code...

app.MapControllers(); // <-- here it is

app.Run();

By reading into it we see it returns IEndpointRouteBuilder interface with a collection of something called EndpointDataSource. These data sources have collections with Endpoint item type. Is it where we should change something?

Yes. By further examining seems the endpoint item in this collection represents exactly one controller action. So we just need to remove unwanted endpoints from the collection here.

That would be the easy way, however, the collection is read-only. We need to find some other solution.

Fortunately, the kind ASP framework developers made Endpoint class properties abstract. And the DataSources property in IEndpointRouteBuilder is a simple, not read-only collection. Bingo! We can leverage this.

How to get the current environment?

This one is much simpler, as by the design current runtime environment is available as app builder property on startup: builder.Environment. It is of type IHostEnvironment and has EnvironmentName property, which will be useful later on.

💡
If you want to know in-depth, where is environment value taken from: read the documentation or refer to the excellent article by Andrew Lock, to know how to set it by yourself.

For our purpose, it is enough to know that, if not specified explicitly, it is set to "Production", like for example on your hosting server. But simply launching a project from VS sets it to "Development" by default.

Marking Endpoints

We have all the knowledge we need now, so let's make the changes.

First of all, we need a way to select which endpoints should be excluded. And what is the better way to do it in C# than adding an attribute? That is what are they for.

Our attribute could look like the one below.

[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class ExcludeOnEnvironmentsAttribute : Attribute
{
    public ExcludeOnEnvironmentsAttribute(params string[] environments)
    {
        Environments= environments;
    }

    public string[] Environments{ get; }
}

Few things to notice here:

  • We will need to mark specific endpoints (methods) or sometimes whole controllers (classes).

  • We can specify multiple environments to exclude endpoint from, this gives more flexibility.

Finding endpoints

As we know already where the endpoint sources are added, we can see that each endpoint has also a set of metadata describing the action it represents. Fortunately for us, it also keeps the custom attributes in it.

💡
All metadata including attributes is derived from the controller class, so each endpoint will have it if either defined explicitly on the method or class.

We check if the endpoint has ExcludeOnEnvironmentsAttribute is defined and the current environment is on the exclude list.

public static bool IsExcludedOnEnvironment(
    this Endpoint endpoint, string environment)
{
       var envExcludeAttributes = endpoint.Metadata
            .OfType<ExcludeOnEnvironmentsAttribute>().ToList();

       foreach (var excludeAttr in envExcludeAttributes)
       {
           if (excludeAttr.Environments.Contains(environment))
           {
               return true;
           }
       }

       return false;
}

Removing endpoints

Now we proceed to disturb the original flow and order the EndpointDataSource to filter out excluded endpoints. We'll do two things:

  1. Define our own EndpointDataSource class, which wraps the original one and applies the filter to the list of endpoints.
public class FilteredEndpointDataSource : EndpointDataSource
{
    private readonly EndpointDataSource originalDataSource;
    private readonly string currentEnvironment;

    public FilteredEndpointDataSource(
                    EndpointDataSource originalDataSource, 
                    string currentEnvironment)
    {
        this.originalDataSource = originalDataSource;
        this.currentEnvironment = currentEnvironment;
    }

    public override IReadOnlyList<Endpoint> Endpoints =>
        originalDataSource.Endpoints
            .Where(e => !e.IsExcludedFromEnvironment(currentEnvironment))
                .ToList().AsReadOnly();

    public override IChangeToken GetChangeToken() => 
         originalDataSource.GetChangeToken();
}
  1. Replace each source in IEndpointsRouteBuilder with the implementation of the new filtered one.
public static class ControllerEndpointRouteBuilderExtensions
{
    public static IEndpointRouteBuilder RemoveExcludedOnEnvironment(
        this IEndpointRouteBuilder endpoints, IHostEnvironment environment)
    {
        ArgumentNullException.ThrowIfNull(endpoints, nameof(endpoints));

        void ReplaceWithFilteredSource(EndpointDataSource originalSource)
        {
            // Wrap here
            var filteredEndpointDataSource = new FilteredEndpointDataSource(
                            originalSource, environment.EnvironmentName);
            // Remove original from list
            endpoints.DataSources.Remove(originalSource);
            // Add filtered one instead
            endpoints.DataSources.Add(filteredEndpointDataSource);
        }

        endpoints.DataSources.ToList().ForEach(ReplaceWithFilteredSource);

        return endpoints;
    }
}

Wrapping it all up

Having it all set we can now mark the endpoint we want to remove.

[HttpGet("api/v2/weather-forecast", Name = "GetWeatherForecastV2")]
[ExcludeOnEnvironments("Production")] // <- Added this
public IEnumerable<WeatherForecastV2> GetV2()
{
    return Enumerable.Range(1, 5).Select(index => new WeatherForecastV2
    {
         Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
         TemperatureC = Random.Shared.Next(-20, 55),
         Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    }) .ToArray();
}

Let us use the fresh new RemoveExcludedOnEnvironment extension method while building the application.

Extension must be used after the MapEndpoints() method is called, so we have the original set of endpoint sources ready to modify.
app.MapControllers();
app.RemoveExcludedOnEnvironment(builder.Environment); // <-- Added

We run the application in with set production environment to test it.

dotnet run --environment "Production"

Now calling the endpoint gives us a 404 (NotFound) response. Success!

Final thoughts

We managed to achieve our acceptance criteria regarding the asp.net core framework.

😥
Unfortunately, we did not achieve the second point of the acceptance criteria. The endpoint is still visible in Swagger docs. But we will deal with it in the second part of this series.

The proposed solution should be easy to use or reconfigure by any developer that will follow our tracks. And what's even better we do it all in a more performant way, than the standard framework mechanisms allow us.

I hope you learned something today. To help you get the grip of it, let me just put my lessons learned below. Section I plan to finish every article with, something to think about.

Lessons learned

  • It is good to think of nonfunctional requirements when designing the solution, even if the business does not ask for it.

  • Analyzing used framework code can be necessary to achieve more advanced or unusual goals.

  • Knowledge of the framework architecture is required to understand, where to jump into in ASP pipeline with our code.

  • But in the end, it gives us a lot of flexibility to work with (if we dare).

Even a couple of years of experience might not be enough if we never diverged from the well-known pathways. Therefore it is always good to start by reading the documentation of the tool/framework/library and then keep digging into the codebase before doing something unusual with it. At least that is what I prefer to do.

Extending the solution

The solution here works for simple static string comparison, but it can be easily extended by using a dynamic predicate to check if the endpoint is to be removed. Heck, we could even call DB or external resources here, if we want.

Just remember: this is all done on startup. Every operation done here adds to the application launch time. And even worse: an unhandled exception thrown somewhere here may end up with the application shutdown. So be careful with too much "smart" code here and try to stick with static values saved in configuration or hosted resources first if possible.

Thank you

This was my first-ever article. Thank you for getting to the end. If I have mistaken something, or if there is a better way to do it, or if you have any thoughts regarding this article, please do not hesitate to put them in the comments below. This is how we all learn after all.

Thank you once again, and stay tuned for part two where we will deal with swagger docs and I will post a link to the working solution on GitHub.