Disabling endpoints on different environments (2/2: Swagger)

Disabling endpoints on different environments (2/2: Swagger)

How to entirely remove endpoints from swagger documentation based on any predicate and why document filters are not a good fit for that. Continuation from Part 1 where we excluded endpoints from ASP pipeline.

TLDR;

Provide a predicate function that checks if the controller action has ExcludeOnEnvironmentsAttribute (defined in Part 1) assigned, and use it in DocInclusionPredicate filter in the swagger configuration on startup.

Scenario

The scenario is the same as in Part 1, but we did not manage to keep the excluded endpoint out of swagger documentation and open API schema. It is still there, even though it can not be called. This is due to Swagger not using the same list of actions as defined in ASP pipeline and reading them from controller classes directly.

We need to give him a hint about that.

Acceptance Criteria

So the AC looks like this now:

  • 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. (This one to keep in mind)

Anything that works now?

There are a few tricks we can use to make Swagger remove any action from the documentation:

  1. Marking controller action private - obviously, this is not a solution, as we can not make it configurable in code (Unless doing some source generation magic tricks, but this is out of the scope of this article)

  2. Using [ApiExplorerSettings(IgnoreApi = true)] on excluded actions - this was mentioned in Part 1 and works well for the documentation but still we can not make it dependent on the environment or other runtime state-based predicate.

None of these would work in our case. And it would be good to reuse what we have done already in the previous part, rather than creating a new specific implementation.

Fortunately, there are some ways to do it, that Swashbuckle library provides.

Document filters

The first and most obvious one is to create IDocumentFilter implementation. If you do not know, the library allows us to manipulate output documentation with much granularity by providing our own implementations of: IDocumentFilter, IOperationFilter or ISchemaFilter.

The 'document' ones decide on the structure of the generated OpenAPI schema and therefore of UI output. This is the way we want to modify our documentation.

Implementation

In the filter implementation, we just need to check each action description for the existence of our custom attribute and compare it with the current environment. If there is a match we remove the endpoint path from swagger documentation.


    public class ExcludeOnEnvironmentsDocumentsFilter : IDocumentFilter
    {
        private readonly IHostEnvironment hostEnvironment;

        public ExcludeOnEnvironmentsDocumentsFilter(
            IHostEnvironment hostEnvironment)
        {
            this.hostEnvironment = hostEnvironment;
        }

        public void Apply(OpenApiDocument swaggerDoc, 
                          DocumentFilterContext context)
        {
            foreach (var actionDescriptor in context.ApiDescriptions)
            {
                if (actionDescriptor.CustomAttributes()
                    .OfType<ExcludeOnEnvironmentsAttribute>()
                    .Any(attr =>  attr.Environments.Contains(
                            hostEnvironment.EnvironmentName)))
                {
                    swaggerDoc.Paths
                        .Remove("/" + actionDescriptor.RelativePath); 
                        // the slash '/' is required here for match
                }
            }
        }
    }

And registering the filter in swagger configuration":

// Program.cs
// ...

// swagger using document filter
builder.Services.AddSwaggerGen(
    opt => opt.DocumentFilter<ExcludeOnEnvironmentsDocumentsFilter>());
// other registrations

Let's run and check the result.

Hissing endpoints in Swagger using document filter

Hurray! The endpoint is gone!

Job done right? Well... not exactly. The watchful eye will spot the problem here. Do you see it?

The issue

We managed to remove the endpoint from the visible output, but the models used by it are still there. Some might not botter, but for me, this is acceptance criteria not met. Seems it is not so simple after all.

We can notice that these model elements are present in OpenApiDocument.Components collection. But there is no direct connection between them and paths, they are used with. This linking can be extracted from DocumentFilterContext.ApiDescriptions. After some digging, I figured out, that for this to work with other elements, we would need to:

  1. traverse through all schema elements in a swagger context (this includes response types, route values, parameters etc.),

  2. match the types they present with components of actions to be removed,

  3. verify if they are not used in any other actions

  4. and remove them from the collection.

A lot of work. Especially the second point, as types can be nested which adds to complexity.

💡
It looks like swagger filters are mostly about adding and changing existing content, rather than removing anything from it.

I stopped here and asked myself if there really was no other way. I dropped this idea, to dig a bit deeper.

If you want to try it anyway, this StackOverflow topic might give you a hint. Although the case described here is more advanced, the solution provided does a lot of what we would also need to do.

Let me know in the comment, how it went. In the meantime I will show you one other way, we can work with.

Document inclusion predicate

I started thinking, that similarly to the way we did it in the ASP.net pipeline, here we also should try to intercept documentation generation flow earlier: before it creates output.

While reading all the solutions (which 90% pointed me back to the document filter) I noticed sometimes used but not explained, little configuration option: DocInclusionPredicate. Once again, thank you good devs for the meaningful names!

It so happens, that this predicate decides about controller action finding its place in documentation or rather being thrown away. And this was exactly what I needed.

// Program.cs
// services registration...

// swagger config
builder.Services.AddSwaggerGen(opt =>
    opt.DocInclusionPredicate((_, apiDescription) => {
            // search for matching excluded environment attribute declaration
            if (apiDescription.ActionDescriptor.EndpointMetadata
                .OfType<ExcludeOnEnvironmentsAttribute>()
                .Any(attr => attr.Environments
                    .Contains(builder.Environment.EnvironmentName)))
                return false;

            return true;
        }));

// rest of setup...

If you use multiple API versions or generate multiple documents, you need to add one condition at the start of the predicate, as below. Otherwise, the document will accept endpoints that do not belong to him.

// swagger config
builder.Services.AddSwaggerGen(opt =>
    opt.DocInclusionPredicate((documentId, apiDescription) => {
            // check only endpoints belonging to this document
            if(documentId != apiDescription.GroupName) // ADDED THIS
                return false;
            // REST STAYS SAME
        }));

And that's done. It is all now working as desired.

Bonus: Versioning api

Either approach we use has the same flaw: if it happens we remove all endpoints from the document, it will still be shown in the top right dropdown menu. There is no point in showing that.

This is especially problematic if we configure and use API versioning for example the way described here.

To address this issue, let us check if documents matching version names are empty, and do not show them at all. Some external services are required, but at this point, we have them available.

 // Program.cs
 app.UseSwaggerUI(cfg => {
    // Versions discovery service
    var apiVersionProvider = app.Services
            .GetRequiredService<IApiVersionDescriptionProvider>();
    // Swagger documents output provider
    var swaggerProvider = app.Services.GetRequiredService<ISwaggerProvider>();

    foreach (var groupName in apiVersionProvider.ApiVersionDescriptions
                .Select(versionInfo => versionInfo.GroupName))
    {
        if (swaggerProvider.GetSwagger(groupName).Paths.Any())
        {
            // Only add document if there is any endpoint in it
            cfg.SwaggerEndpoint($"{groupName}/swagger.json", 
                        $"Service API {groupName}");
        }
    }
 });

Extending the solution

Similarly to part 1, we can make our exclusion/inclusion predicate more dynamic and runtime-based. Moving all the swagger configuration setup to separate configuration class would make it more coherent, and enable the proper usage of dependency injection.

public class MySwaggerOptions : IConfigureNamedOptions<SwaggerGenOptions>
{
    public MySwaggerOptions(ISuperComplexPredicateService predicateService)
    { ... }

    public void Configure(SwaggerGenOptions options)
    {
          options.DocInclusionPredicate(predicateService.IncludeDoc);
          // rest of cfg setup like versions, authorization etc. goes here
    }
}

// Program.cs
builder.Services.AddSwaggerGen().ConfigureOptions<MySwaggerOptions>();

I like this approach in one class I have all the needed setup, which is often reused between projects and I have a decluttered program.cs file. But this opts to personal preferences.

Lessons learned

As always we will try to close with some conclusions, not only about the solution or technology, but also the way of solving it.

  • Sometimes it is good to stop and evaluate how much effort will it take to finalize the solution you are working on.

  • Don't be afraid to go back to the start and explore alternatives, as they might take less time and be

  • Sometimes documentation does not provide us with enough alternatives. The ability to analyze someone else's code comes in handy in these cases.

As promised, here is the working application code with all solutions from part 1 and 2 put together plus some bonus. Check it. Thank you.