Hide endpoints for disabled features in OpenAPI

June 7th 2024 OpenAPI ASP.NET Core

The .NET Feature Management libraries are great for toggling the availability of ASP.NET Web API endpoints with feature flags. Unfortunately, such endpoints will still show up in the generated OpenAPI specification even when the feature flag is disabled. To hide them, you need to implement a custom filter for Swashbuckle.

Only a minimum amount of code is needed to put an endpoint behind a feature flag:

  • Install the Microsoft.FeatureManagement.AspNetCore NuGet package in your project.
  • Add the library to dependency injection:
    builder.Services.AddFeatureManagement();
    
  • Create a class with constants for feature flags to refer to them in code in a more strongly typed manner:
    public static class FeatureFlags
    {
        public const string PutWeatherForecast = nameof(PutWeatherForecast);
    }
    
  • Add the a FeatureGate attribute to the action method for the endpoint you want to toggle the availability for:
    [FeatureGate(FeatureFlags.PutWeatherForecast)]
    [HttpPut(Name = "PutWeatherForecast")]
    public void Put()
    {
        throw new NotImplementedException();
    }
    
  • Add a FeatureManagement section to your apisettings.json file:
    {
      "FeatureManagement": {
        "PutWeatherForecast": "false"
      }
    }
    

This will make the endpoint unavailable when the feature is disabled. any calls to it will result in a 404 Not Found response:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-c42c0c72a95674785ea4bacc4f2027b5-c39838452224ca35-00"
}

However, the endpoint will still show up in the generated OpenAPI specification:

Disabled endpoint in OpenAPI

To hide it, a custom IDocumentFilter for Swashbuckle is required which checks the FeatureGate attribute for each endpoint in the document and removes it if it determines that the endpoint is disabled:

public class FeatureGateDocumentFilter(IFeatureManager featureManager)
    : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        foreach (var apiDescription in context.ApiDescriptions)
        {
            var filterPipeline = apiDescription.ActionDescriptor.FilterDescriptors;
            var featureAttributes = filterPipeline
                .Select(filterInfo => filterInfo.Filter)
                .OfType<FeatureGateAttribute>()
                .ToList();

            bool endpointEnabled = true;
            foreach (var attribute in featureAttributes)
            {
                var featureValues = attribute.Features.Select(feature =>
                    featureManager.IsEnabledAsync(feature).GetAwaiter().GetResult()
                );
                endpointEnabled &=
                    attribute.RequirementType == RequirementType.Any
                        ? featureValues.Any(isEnabled => isEnabled)
                        : featureValues.All(isEnabled => isEnabled);
            }

            if (!endpointEnabled)
            {
                var path = $"/{apiDescription.RelativePath}";
                var apiPath = swaggerDoc.Paths[path];
                if (apiPath != null)
                {
                    if (
                        Enum.TryParse<OperationType>(
                            apiDescription.HttpMethod,
                            true,
                            out var operationType
                        )
                    )
                    {
                        apiPath.Operations.Remove(operationType);
                    }

                    if (apiPath.Operations.Count == 0)
                    {
                        swaggerDoc.Paths.Remove(path);
                    }
                }
            }
        }
    }
}

I based the code on a Stack Overflow answer. I only cleaned up the code a bit and fixed it so that it works correctly even in cases when there are multiple endpoints with different verbs for the same path. It might still not cover all the cases, but it works well enough in my project for now.

You might have noticed I'm using GetAwaiter().GetResult() to call the asynchronous IsEnabledAsync() method synchronously. Unfortunately I have to because there is no synchronous method for checking the feature flag state and there is no asynchronous version of IDocumentFilter.

For the filter to be used, you need to register it when adding Swashbuckle to dependency injection:

builder.Services.AddSwaggerGen(options =>
    options.DocumentFilter<FeatureGateDocumentFilter>()
);

You can check full source code for a small sample project in my GitHub repository. Try enabling the feature to see how the endpoint both shows up in the OpenAPI specification and starts working again.

If you need to support feature flags in your application, you should consider using the .NET Feature Management libraries instead of coming up with your own solution. Although it might not support all your use cases out-of-the-box yet, it's likely that it will be easier to extend it with the missing features than to implement everything from scratch.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License