Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Filter API endpoints by consumer using Swagger UI and Swashbuckle

I've researched this issue and found a lot of articles and also q+as on here but nothing for my scenario. I have an asp.net core 3 API with 2 versions, 1 and 2. The API has 3 consumers, ConA, ConB, and ConC, and 3 controllers. ConA accesses controllers 1 and 2, ConB accesses only controller 3, and ConC accesses one endpoint from controller 1 and one endpoint from controller 3. For v1 I show everything but I now have a requirement to filter v2 endpoints by API consumer.

What I'm trying to do is create a Swagger document for each consumer that only shows the endpoints they can access. It's easy to do for ConA and ConB as I can use [ApiExplorerSettings(GroupName = "v-xyz")] where v-xyz can be restricted by consumer and then split the Swagger documents that way. The problem is showing the endpoints for ConC - they don't have their own controller so I can't give them a GroupName. Here's a simplified version of the code:

public void ConfigureServices(IServiceCollection services)
{
    services.AddApiVersioning(options =>
    {
        options.ReportApiVersions = true;
        options.AssumeDefaultVersionWhenUnspecified = true;
        options.DefaultApiVersion = new ApiVersion(1, 0);
    });

    services.AddVersionedApiExplorer(options =>
    {
        options.GroupNameFormat = "'v'VV";
        options.SubstituteApiVersionInUrl = true;
    });

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo() { Title = "My API - Version 1", Version = "v1.0" });
        c.SwaggerDoc("v2-conA", new OpenApiInfo() { Title = "My API - Version 2", Version = "v2.0" });
        c.SwaggerDoc("v2-conB", new OpenApiInfo() { Title = "My API - Version 2", Version = "v2.0" });
        c.SwaggerDoc("v2-conC", new OpenApiInfo() { Title = "My API - Version 2", Version = "v2.0" });

        c.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
        c.EnableAnnotations();
    });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseSwagger();

    app.UseSwaggerUI(c =>
    {
        c.EnableDeepLinking();
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
        c.SwaggerEndpoint("/swagger/v2-conA/swagger.json", "My API V2 ConA");
        c.SwaggerEndpoint("/swagger/v2-conB/swagger.json", "My API V2 ConB");
        c.SwaggerEndpoint("/swagger/v2-conC/swagger.json", "My API V2 Con3");
    });
}

Version 1 controllers:

[Route("api/account")]
[ApiController]
[ApiExplorerSettings(GroupName = "v1")]
public class AccountController : ControllerBase
{
    [HttpGet("get-user-details")]
    public ActionResult GetUserDetails([FromQuery]string userId)
    {
        return Ok(new { UserId = userId, Name = "John", Surname = "Smith", Version = "V1" });
    }
}

[Route("api/account-admin")]
[ApiController]
[ApiExplorerSettings(GroupName = "v1")]
public class AccountAdminController : ControllerBase
{
    [HttpPost("verify")]
    public ActionResult Verify([FromBody]string userId)
    {
        return Ok($"{userId} V1");
    }
}

[Route("api/notification")]
[ApiController]
[ApiExplorerSettings(GroupName = "v1")]
public class NotificationController : ControllerBase
{
    [HttpPost("send-notification")]
    public ActionResult SendNotification([FromBody]string userId)
    {
        return Ok($"{userId} V1");
    }
}

Version 2 controllers (namespaced in separate folder "controllers/v2"):

[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/account")]
[ApiController]
[ApiExplorerSettings(GroupName = "v2-conA")]
public class AccountController : ControllerBase
{
    [HttpGet("get-user-details")]
    [SwaggerOperation(Tags = new[] { "ConA - Account" })]
    public ActionResult GetUserDetails([FromQuery]string userId)
    {
        return Ok($"{userId} V2");
    }
}

[Route("api/v{version:apiVersion}/account-admin")]
[ApiController]
[ApiVersion("2.0")]
[ApiExplorerSettings(GroupName = "v2-conB")]
public class AccountAdminController : ControllerBase
{
    [HttpPost("verify")]
    [SwaggerOperation(Tags = new[] { "ConB - Account Admin", "ConC - Account Admin" })]
    public ActionResult Verify([FromBody] string userId)
    {
        return Ok($"{userId} V2");
    }
}

[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/notification")]
[ApiController]
[ApiExplorerSettings(GroupName = "v2-conA")]
public class NotificationController : ControllerBase
{
    [HttpPost("send-notification")]
    [SwaggerOperation(Tags = new[] { "ConA - Notification", "ConC - Notification" })]
    public ActionResult SendNotification([FromBody] string userId)
    {
        return Ok($"{userId} V2");
    }
}

This gets me some of the way in that I can see the endpoints for ConA and ConB, although it's not perfect as it's showing duplicate endpoints, but I'm stuck on how to show the endpoints for ConC (who can see one endpoint from controller 1 and one from controller 3). My next attempt will be to go back to showing all endpoints in version 2 and then filtering using IDocumentFilter if I can't get the above working somehow. Any thoughts or tips greatly appreciated 👍

Swagger doc selector

Swagger endpoints using above code

No endpoints for ConC :(

like image 759
some_randomer Avatar asked Nov 07 '22 03:11

some_randomer


1 Answers

I had to do this recently, we also had multiple consumers and needed to filter the endpoints per consumer. I used a DocumentFilter and filtered the endpoints using tags.

There's a fair bit of code in it so I stuck the full solution on Github: https://github.com/cbruen1/SwaggerFilter

public class Startup
{
    private static Startup Instance { get; set; }

    private static string AssemblyName { get; }

    private static string FullVersionNo { get; }

    private static string MajorMinorVersionNo { get; }

    static Startup()
    {
        var fmt = CultureInfo.InvariantCulture;
        var assemblyName = Assembly.GetExecutingAssembly().GetName();
        AssemblyName = assemblyName.Name;
        FullVersionNo = string.Format(fmt, "v{0}", assemblyName.Version.ToString());
        MajorMinorVersionNo = string.Format(fmt, "v{0}.{1}",
            assemblyName.Version.Major, assemblyName.Version.Minor);
    }

    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
        Instance = this;
    }

    public IConfiguration Configuration { get; }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();

        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0);

        services.AddApiVersioning(options =>
        {
            options.ReportApiVersions = true;
            options.AssumeDefaultVersionWhenUnspecified = true;
            options.DefaultApiVersion = new ApiVersion(1, 0);
        });

        services.AddVersionedApiExplorer(options =>
        {
            options.GroupNameFormat = "'v'VV";
            options.SubstituteApiVersionInUrl = true;
        });

        // Use an IConfigureOptions for the settings
        services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();

        services.AddSwaggerGen(c =>
        {
            c.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());

            // Group by tag
            c.EnableAnnotations();

            // Include comments for current assembly - right click the project and turn on this otion in the build properties
            var xmlFile = $"{AssemblyName}.xml";
            var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
            c.IncludeXmlComments(xmlPath);
        });
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVersionDescriptionProvider provider)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseHttpsRedirection();

        app.UseRouting();

        app.UseAuthorization();

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

        app.UseSwagger();

        app.UseSwaggerUI(c =>
        {
            c.EnableDeepLinking();

            // Build a swagger endpoint for each API version and consumer
            c.SwaggerEndpoint($"/swagger/{Constants.ApiVersion1}/swagger.json", "MyAccount API V1");
            c.SwaggerEndpoint($"/swagger/{Constants.ApiConsumerGroupNameConA}/swagger.json", $"MyAccount API V2 {Constants.ApiConsumerNameConA}");
            c.SwaggerEndpoint($"/swagger/{Constants.ApiConsumerGroupNameConB}/swagger.json", $"MyAccount API V2 {Constants.ApiConsumerNameConB}");
            c.SwaggerEndpoint($"/swagger/{Constants.ApiConsumerGroupNameConC}/swagger.json", $"MyAccount API V2 {Constants.ApiConsumerNameConC}");

            c.DocExpansion(DocExpansion.List);
        });
    }
}


public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
{
    public void Configure(SwaggerGenOptions options)
    {
        // Filter out api-version parameters globally
        options.OperationFilter<ApiVersionFilter>();

        // Create Swagger documents per version and consumer
        options.SwaggerDoc(Constants.ApiVersion1, CreateInfoForApiVersion("v1.0", "My Account API V1"));
        options.SwaggerDoc(Constants.ApiConsumerGroupNameConA, CreateInfoForApiVersion("v2.0", $"My Account API V2 {Constants.ApiConsumerNameConA}"));
        options.SwaggerDoc(Constants.ApiConsumerGroupNameConB, CreateInfoForApiVersion("v2.0", $"My Account API V2 {Constants.ApiConsumerNameConB}"));
        options.SwaggerDoc(Constants.ApiConsumerGroupNameConC, CreateInfoForApiVersion("v2.0", $"My Account API V2 {Constants.ApiConsumerNameConC}"));

        // Include all paths
        options.DocInclusionPredicate((name, api) => true);

        // Filter endpoints based on consumer
        options.DocumentFilter<SwaggerDocumentFilter>();

        // Take first description on any conflict
        options.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
    }

    static OpenApiInfo CreateInfoForApiVersion(string version, string title)
    {
        var info = new OpenApiInfo()
        {
            Title = title,
            Version = version
        };

        return info;
    }
}

public class SwaggerDocumentFilter : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        // Key is read-only so make a copy of the Paths property
        var pathsPerConsumer = new OpenApiPaths();
        var currentConsumer = GetConsumer(swaggerDoc.Info.Title);
        IDictionary<string, OpenApiSchema> allSchemas = swaggerDoc.Components.Schemas;

        if (swaggerDoc.Info.Version.Contains(Constants.ApiVersion2))
        {
            foreach (var path in swaggerDoc.Paths)
            {
                // If there are any tags (all methods are decorated with "SwaggerOperation(Tags = new[]...") with the current consumer name
                if (path.Value.Operations.Values.FirstOrDefault().Tags
                    .Where(t => t.Name.Contains(currentConsumer)).Any())
                {
                    // Remove tags not applicable to the current consumer (for endpoints where multiple consumers have access)
                    var newPath = RemoveTags(currentConsumer, path);

                    // Add the path to the collection of paths for current consumer
                    pathsPerConsumer.Add(newPath.Key, newPath.Value);
                }
            }

            //// Whatever objects are used as parameters or return objects in the API will be listed under the Schemas section in the Swagger UI
            //// Use below to filter them based on the current consumer - remove schemas not belonging to the current path
            
            //foreach (KeyValuePair<string, OpenApiSchema> schema in allSchemas)
            //{
            //    // Get the schemas for current consumer
            //    if (Constants.ApiPathSchemas.TryGetValue(currentConsumer, out List<string> schemaList))
            //    {
            //        if (!schemaList.Contains(schema.Key))
            //        {
            //            swaggerDoc.Components.Schemas.Remove(schema.Key);
            //        }
            //    }
            //}
        }
        else
        {
            // For version 1 list version 1 endpoints only
            foreach (var path in swaggerDoc.Paths)
            {
                if (!path.Key.Contains(Constants.ApiVersion2))
                {
                    pathsPerConsumer.Add(path.Key, path.Value);
                }
            }
        }

        swaggerDoc.Paths = pathsPerConsumer;
    }

    public KeyValuePair<string, OpenApiPathItem> RemoveTags(string currentConsumer, KeyValuePair<string, OpenApiPathItem> path)
    {
        foreach (var item in path.Value.Operations.Values?.FirstOrDefault().Tags?.ToList())
        {
            // If the tag name doesn't contain the current consumer name remove it
            if (!item.Name.Contains(currentConsumer))
            {
                path.Value.Operations.Values?.FirstOrDefault().Tags?.Remove(item);
            }
        }

        return path;
    }

    private string GetConsumer(string path)
    {
        if (path.Contains(Constants.ApiConsumerNameConA))
        {
            return Constants.ApiConsumerNameConA;
        }
        else if (path.Contains(Constants.ApiConsumerNameConB))
        {
            return Constants.ApiConsumerNameConB;
        }
        else if (path.Contains(Constants.ApiConsumerNameConC))
        {
            return Constants.ApiConsumerNameConC;
        }

        return string.Empty;
    }
}

public class ApiVersionFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        // Remove version parameter field from Swagger UI
        var parametersToRemove = operation.Parameters.Where(x => x.Name == "api-version").ToList();
        foreach (var parameter in parametersToRemove)
        {
            operation.Parameters.Remove(parameter);
        }
    }
}

public static class Constants
{
    // Swagger UI grouping and filtering
    public const string ApiVersion1 = "v1";
    public const string ApiVersion2 = "v2";

    // The full consumer name
    public const string ApiConsumerNameConA = "Consumer A";
    public const string ApiConsumerNameConB = "Consumer B";
    public const string ApiConsumerNameConC = "Consumer C";

    // Specify the group name - this appears in the Swagger UI drop-down
    public const string ApiConsumerGroupNameConA = "v2-conA";
    public const string ApiConsumerGroupNameConB = "v2-conB";
    public const string ApiConsumerGroupNameConC = "v2-conC";

    // Decorate each controller method with the tag names below - this determines 
    // what consumer can access what endpoint, and also how the endpoints are 
    // grouped and named in the Swagger UI

    // Swagger ConA tag names
    public const string ApiConsumerTagNameConAAccount = ApiConsumerNameConA + " - Account";
    public const string ApiConsumerTagNameConANotification = ApiConsumerNameConA + " - Notification";

    // Swagger ConB tag names
    public const string ApiConsumerTagNameConBAccountAdmin = ApiConsumerNameConB + " - Account Admin";

    // Swagger ConC tag names
    public const string ApiConsumerTagNameConCAccountAdmin = ApiConsumerNameConC + " - Account Admin";
    public const string ApiConsumerTagNameConCNotification = ApiConsumerNameConC + " - Notification";

    // Store the schemes belonging to each Path for Swagger so only the relevant ones are shown in the Swagger UI
    public static IReadOnlyDictionary<string, List<string>> ApiPathSchemas;

    static Constants()
    {
        ApiPathSchemas = new Dictionary<string, List<string>>()
        {
            //// Whatever objects are used as parameters or return objects in the API will be listed under the Schemas section in the Swagger UI
            //// Use below to add the list required by each consumer
            
            // Consumer A has access to all so only specify those for B and C
            // { ApiConsumerNameConB, new List<string>() { "SearchOutcome", "AccountDetails", "ProblemDetails" }},
            // { ApiConsumerNameConC, new List<string>() { "NotificationType", "SendNotificationRequest", "ProblemDetails" }}
        };
    }
}

// v1 controllers
[Route("api/account-admin")]
[ApiController]
[ApiExplorerSettings(GroupName = Constants.ApiVersion1)]
public class AccountAdminController : ControllerBase
{
    [HttpPost("verify")]
    public ActionResult Verify([FromBody]string userId)
    {
        return Ok($"{userId} V1");
    }
}

[ApiController]
[ApiExplorerSettings(GroupName = Constants.ApiVersion1)]
public class AccountController : ControllerBase
{
    [HttpGet("api/account/get-user-details")]
    public ActionResult GetUserDetails([FromQuery]string userId)
    {
        return Ok(new { UserId = userId, Name = "John", Surname = "Smith", Version = "V1" });
    }
}

[Route("api/notification")]
[ApiController]
[ApiExplorerSettings(GroupName = Constants.ApiVersion1)]
public class NotificationController : ControllerBase
{
    [HttpPost("send-notification")]
    public ActionResult SendNotification([FromBody]string userId)
    {
        return Ok($"{userId} V1");
    }
}

// v2 controllers
[Route("api/v{version:apiVersion}/account-admin")]
[ApiController]
[ApiVersion("2.0")]
public class AccountAdminController : ControllerBase
{
    [HttpPost("verify")]
    [SwaggerOperation(Tags = new[] { Constants.ApiConsumerTagNameConBAccountAdmin, Constants.ApiConsumerTagNameConCAccountAdmin })]
    public ActionResult Verify([FromBody] string userId)
    {
        return Ok($"{userId} V2");
    }
}

[Route("api/v{version:apiVersion}/account")]
[ApiController]
[ApiVersion("2.0")]
public class AccountController : ControllerBase
{
    [HttpGet("get-user-details")]
    [SwaggerOperation(Tags = new[] { Constants.ApiConsumerTagNameConAAccount })]
    public ActionResult GetUserDetails([FromQuery]string userId)
    {
        return Ok($"{userId} V2");
    }
}

[Route("api/v{version:apiVersion}/notification")]
[ApiController]
[ApiVersion("2.0")]
public class NotificationController : ControllerBase
{
    [HttpPost("send-notification")]
    [SwaggerOperation(Tags = new[] { Constants.ApiConsumerTagNameConANotification, Constants.ApiConsumerTagNameConCNotification })]
    public ActionResult SendNotification([FromBody] string userId)
    {
        return Ok($"{userId} V2");
    }
}

Solution structure:

Solution structure

API filtered for Consumer C:

API filtered for Consumer C

like image 134
Ciarán Bruen Avatar answered Nov 16 '22 12:11

Ciarán Bruen