Controller Groups with Swagger and ASP.NET Core

While creating a web api with a vertical slice/feature approach it may very well be that that we end up with a controller per feature (create, change name, etc.)

Resulting in something like this as project structure:

Project structure

Where in each file, a controller is defined as the interface to handle the command or query for that feature. This results in something like this with swagger ui:

Swagger UI, before

This is fine, but can become unwieldy with time, which is where grouping by a "Group name" can help.

On a controller, add the ApiExplorerSettings and specify the GroupName property. For instance give it the name "Groups". So an adorned controller might look like:

[Authorize]
[Route("bankbog/commands/group/create")]
[Consumes("application/json")]
[Produces("application/json")]
[ApiExplorerSettings(GroupName = "Group")]
public class CreateGroupController(ICommandHandler<CreateGroupCommand, Group> cmdHandler) : ControllerBase
{
    [HttpPost]
    public async Task<Group> CreateGroup([FromBody] CreateGroupCommand command)
    {
        return await cmdHandler.Handle(command);
    }
}

If you run an application now, the controllers might be gone altogether from swagger ui:

Swagger UI, during

This is becuase of an inclusion predicate that can be configured with swagger. By default, at the time of writing, the predicate is implemented like this:

private bool DefaultDocInclusionPredicate(string documentName, ApiDescription apiDescription)
{
    return apiDescription.GroupName == null || apiDescription.GroupName == documentName;
}

So if nothing special has been done, the documentName parameter here is "v1" which is not equal to the apiDescription.GroupName (which is "Group"). There's a few different ways around this situation. One that fits well with my current project in its current state is to just include everything:

services.AddSwaggerGen(options =>
{
    // ... something about security schemes maybe ...

    // Include all controllers in the generated documentation.
    options.DocInclusionPredicate((documentName, apiDescription) => true);
});

At this point, we're back to where the controllers are included in swagger's UI, but still ungrouped. Swagger groups by something they call tags, so we need to set that up in the configuration as well:

services.AddSwaggerGen(options =>
{
    // ... something about security schemes maybe ...

    // Include all controllers in the generated documentation.
    options.DocInclusionPredicate((documentName, apiDescription) => true);

    // Tag/group actions by their GroupName property if it is set, otherwise by
    // the controller name like normal.
    options.TagActionsBy(api =>
    {
        if (api.GroupName != null)
            return new[] { api.GroupName };

        if (api.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor)
            return new[] { controllerActionDescriptor.ControllerName };

        throw new InvalidOperationException("Unable to determine tag for endpoint.");
    });
});

Result

We now ended up with something that looks like this in swagger: Swagger, result