1

I have two Swagger documents:

options.SwaggerDoc("v1", new OpenApiInfo
{
    Title = "V1",
    Version = "v1"
});
options.SwaggerDoc("v2", new OpenApiInfo
{
    Title = "V2",
    Version = "v2"
});

I'd like to apply a CustomOperationIds to one of them, and another type to the other, so that the operation IDs of V2 are different than V1.

Is this possible?

Mathias Lykkegaard Lorenzen
  • 15,031
  • 23
  • 100
  • 187
  • Are you using `Microsoft.AspNetCore.Mvc.Versioning` for versioning? Or how do you differentiate versions of the same API? – abdusco Jun 26 '21 at 16:20

2 Answers2

2

Yes, it's possible. As you've discovered, Swashbuckle offers SwaggerGenOptions.CustomOperationIds() extension point. We can hook into that.

You need to have a controller with actions annotated with [ApiExplorerSettings] attribute. This ensures the actions end up in the correct OpenAPI document.

Here I'm using Microsoft.AspNetCore.Mvc.Versioning library for API versioning. This way I can use the same path for different actions corresponding to different versions. But it's not a requisite.

[ApiController]
[Route("api/[controller]")]
public class StuffController: ControllerBase
{
    [HttpGet("")]
    [ApiVersion("1.0")]
    [ApiExplorerSettings(GroupName = "v1")]
    public IActionResult GetStuffTheOldWay()
    {
        return Ok(nameof(GetStuffTheOldWay));
    }
    
    [HttpGet("")]
    [ApiVersion("2.0")]
    [ApiExplorerSettings(GroupName = "v2")]
    public IActionResult GetStuffTheNewWay()
    {
        return Ok(nameof(GetStuffTheNewWay));
    }
}

Then we can utilize that group name when building operation ids.

services.AddSwaggerGen(
    c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "ApiPlayground", Version = "v1" });
        c.SwaggerDoc("v2", new OpenApiInfo { Title = "ApiPlayground", Version = "v2" });
        c.CustomOperationIds(
            description =>
            {
                if (!(description.ActionDescriptor is ControllerActionDescriptor actionDescriptor))
                {
                    return null; // default behavior
                }

                return description.GroupName switch
                {
                    "v1" => $"Old{actionDescriptor.ActionName}",
                    "v2" => $"New{actionDescriptor.ActionName}",
                    _ => null // default behavior
                };
            });
    }
);

This gives us two OpenAPI documents, that correctly prefixes the operation ids depending on [ApiVersionAttribute] group.

// v1 API:

{
  "openapi": "3.0.1",
  "info": {
    "title": "ApiPlayground",
    "version": "v1"
  },
  "paths": {
    "/api/Stuff": {
      "get": {
        "tags": [
          "Stuff"
        ],
        "operationId": "OldGetStuffTheOldWay", // <---
        // ...
}

// v2 API:
{
  "openapi": "3.0.1",
  "info": {
    "title": "ApiPlayground",
    "version": "v2"
  },
  "paths": {
    "/api/Stuff": {
      "get": {
        "tags": [
          "Stuff"
        ],
        "operationId": "NewGetStuffTheNewWay", // <---
        // ...
}

Once you have the actionDescriptor you have access to a lot of metadata ASP.NET Core provides for you to play with:

debug

Setting default parameters depending on version in Swagger UI

Swagger UI is nice, but it only executes the action for default API version. We need to specify the API version in as a query parameter api-version=1.0 or in a header, or as a part of the URL. To express this requirement, we can modify the OpenAPI document a bit and add a version parameter that defaults to whichever version that endpoint corresponds to. That is:

  • v1 -> must have ?api-version=1.0 parameter
  • v2 -> must have ?api-version=2.0 parameter

and so on. Swashbuckle has another extension point, operation filters.

public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddSwaggerGen(
        c =>
        {
            c.SwaggerDoc("1.0", new OpenApiInfo { Title = "ApiPlayground", Version = "v1" });
            c.SwaggerDoc("2.0", new OpenApiInfo { Title = "ApiPlayground", Version = "v2" });
            c.CustomOperationIds(...);
            c.OperationFilter<ApiVersionFilter>(); // <---
        }
    );
}


private class ApiVersionFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        // find all defined versions
        var versions = context.ApiDescription
            .ActionDescriptor
            .EndpointMetadata
            .OfType<ApiVersionAttribute>()
            .SelectMany(a => a.Versions)
            .Select(v => v.ToString()).ToList();
        
        if (!versions.Any())
        {
            return;
        }
        
        // extend openapi schema with a version selector
        var firstVersion = versions.First();
        var versionEnum = versions.Select(v => new OpenApiString(v)).Cast<IOpenApiAny>().ToList();
        operation.Parameters.Add(
            new OpenApiParameter
            {
                In = ParameterLocation.Query,
                Name = "api-version",
                Description = "The version of the API you want to call",
                Example = new OpenApiString(firstVersion),
                Schema = new OpenApiSchema
                {
                    Type = "string",
                    Enum = versionEnum
                }
            }
        );
    }
}

Once done, we get a UI with api-version value filled in.

api version

abdusco
  • 9,700
  • 2
  • 27
  • 44
1

Serving an existing Swagger document with different operation IDs

This is a bit involved. Swashbuckle lets us filter single document at a time. We should be able to inject ISwaggerProvider inside a document filter, which would simplify things quite a bit, but I couldn't get it to work. Nonetheless, we can decorate the existing ISwaggerProvider implementation and serve the modified document.

Step 1: Set operation ids

Actions have no operation ids by default, so use SwaggerGenOptions.CustomOperationIds() to populate the values.

services.AddSwaggerGen(
    c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "ApiPlayground", Version = "v1" });
        // use action names as operations ids if present
        c.CustomOperationIds(
            description => description.ActionDescriptor is not ControllerActionDescriptor actionDescriptor
                ? null
                : actionDescriptor.ActionName);
    }
);

Step 2: Define two documents for Swagger UI

This gets Swagger UI to show two different documents in the doc dropdown menu. Here I've defined two documents, one with ops suffix, and one without.

// inside Startup class
public void Configure(IApplicationBuilder app)
{
    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "ApiPlayground v1");
        c.SwaggerEndpoint("/swagger/v1ops/swagger.json", "ApiPlayground v1ops");
        c.DisplayOperationId();
    });
    // ...
}

Here's how it looks:

swagger ui

We'll intercept the request for v1ops and serve a modified document.

Step 3: Implement a custom ISwaggerProvider

This is necessary to serve a custom OpenAPI document while still being able to access existing documents to source the actual values. Once we get the original document, we modify each endpoint's operation id.

So, I've written a proxy that subclasses SwaggerGenerator and implements ISwaggerProvider at the same time. This ensures that the method we've hidden with new keyword actually gets called.

Here, this class intercepts document requests and checks if the document name is suffixed with ops, then serves the original document with modified operation ids.

class SwaggerDocCustomOperationIdProvider : SwaggerGenerator, ISwaggerProvider
{
    public SwaggerDocCustomOperationIdProvider(SwaggerGeneratorOptions options, IApiDescriptionGroupCollectionProvider apiDescriptionsProvider, ISchemaGenerator schemaGenerator) : base(options, apiDescriptionsProvider, schemaGenerator)
    {
    }

    public new OpenApiDocument GetSwagger(string documentName, string host = null, string basePath = null)
    {
        if (!documentName.EndsWith("ops"))
        {
            return base.GetSwagger(documentName, host, basePath);
        }

        var sourceDoc = documentName.Replace("ops", "");
        var doc = base.GetSwagger(sourceDoc, host, basePath);

        // dont mutate the info props, because Swashbuckle caches the docs
        doc.Info = new OpenApiInfo
        {
            Title = $"{doc.Info.Title} - with operation ids",
            Version = doc.Info.Version,
        };

        var operations = doc.Paths
            .SelectMany(p => p.Value.Operations.Values)
            .ToList();

        foreach (var op in operations)
        {
            // change the operation id
            op.OperationId = $"Cloned{op.OperationId}";
        }

        return doc;
    }
}

Then, we register it in DI container.

public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddSwaggerGen(/* ... */);
    // get swashbuckle to use our implementation
    services.AddTransient<ISwaggerProvider, SwaggerDocCustomOperationIdProvider>();
}

Result

The original document: original doc

One with custom operation ids: doc with custom op ids

Caveats:

Since we're modifying the final document, we don't have access to reflection data like the original SwaggerGenerator. This means you can't easily refer to runtime method information. But you can always implement an IDocumentFilter and prepare the schema from scratch.

abdusco
  • 9,700
  • 2
  • 27
  • 44