loading...
Cover image for Versioning ASP.Net Core APIs with Swashbuckle - Making Space Potatoes V-x.x.x

Versioning ASP.Net Core APIs with Swashbuckle - Making Space Potatoes V-x.x.x

htissink profile image Henrick Tissink ・4 min read

Creating a new API with ASP.NET Core is fun and easy; versioning that API is a bit harder. The cinch though is how generating Swagger for the initial version is a breeze while generating versioned swagger is a little trickier.

Disclaimer: due to bugs in ASP.Net Core 2.2.0, please use ASP.Net Core 2.1.0

Let's dive into this by creating a simple ASP.Net Core Api (2.1.0) to give us some Space Potatoes. First we'll create a controller called SpaceFarmController.

[Route("api/[controller]")]
[ApiController]
public class SpaceFarmController : ControllerBase
{
    [HttpGet("Potatoes")]
    public string SpacePotatoes() => "Space Potatoes v1";
}

Now, let's add some versioning to this little API. Add the following Nuget Package to your project:

  • Microsoft.AspNetCore.Mvc.Versioning (version 3.0.0)

Our API will be versioned using Route Versioning, this simply means that our API will be versioned according to its routes, with

version route
v1 .../api/controller/v1/something
V2 .../api/controller/v2/something
v{n} .../api/controller/v{n}/something

The API can now easily be versioned by adding attributes to the controller:

[ApiVersion("1")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public class SpaceFarmController : ControllerBase
{
    [HttpGet("Potatoes")]
    public string SpacePotatoes() => "Space Potatoes v1";
}

And modifying the Startup.cs file:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddApiVersioning(); // just add this
}

Running your app locally, you'll now be able to get some Space Potatoes by making a GET request to https://localhost:5001/api/v1/spacefarm/potatoes.

Let's add some Swagger generation to the mix. For this we'll be using Swashbuckle. Add the following Nuget Package to your project:

  • Swashbuckle.AspNetCore (version 4.0.1)

Adding Swagger generation to your project is now as simple as adding the following to your Startup.cs file:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddApiVersioning();

    // Add this
    services.AddSwaggerGen(options => 
        options.SwaggerDoc("v1", new Info
        {
            Version = "v1",
            Title = "v1 API",
            Description = "v1 API Description",
            TermsOfService = "Terms of Service v1"
        }));
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    loggerFactory.AddConsole(Configuration.GetSection("Logging"));
    loggerFactory.AddDebug();

    // And add this, an endpoint for our swagger doc 
    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint($"/swagger/v1/swagger.json", $"v1");
    });
    app.UseMvc();
}

For a final trick, change your launchSettings.json entry for your application to:

"*YOUR APPLICATION NAME HERE*": {
      "commandName": "Project",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "https://localhost:5001;http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }

Now when you run your application, it immediately launches the Swagger page.

If you've done all this correctly, when you run your app you should see a screen similar to:

Alt Text

This is great - you've got Swagger generation and a v1 of your API. But it's an ever changing world in the Space Potato market, and pretty soon you're going to want to create an additional way to get Space Potatoes - a v2.0 if you will.

Now, adding another version is quite easy, but getting the Swagger generation to work with this new version is a bit tricky.

First, let's add a v2 for our endpoint in the SpaceFarmController.cs:

[Route("api/v{version:apiVersion}/[controller]")]
// Which versions the controller responds to
[ApiVersion("1")]
[ApiVersion("2")]
[ApiController]
public class SpaceFarmController : ControllerBase
{
    [HttpGet]
    // Which version the route corresponds to
    [MapToApiVersion("1")]
    [Route("Potatoes")]
    public string GetPotatoes() => "Space Potatoes v1";

    [HttpGet]
    // Which version the route corresponds to
    [MapToApiVersion("2")]
    [Route("Potatoes")]
    public string GetPotatoesV2() => "Space Potatoes v2";
}

Great, fantastic even. We now have two versions of our API. If we run this, we can make requests to https://localhost:5001/api/v1/spacefarm/potatoes and https://localhost:5001/api/v2/spacefarm/potatoes (but the Swagger generation will fail).

Swashbuckle doesn't know that there is a difference in the two routes - it sees them as one and the same. So let's help Swashbuckle out. For this we'll need to create two classes:

public class RemoveVersionFromParameter : IOperationFilter
{
    public void Apply(Operation operation, OperationFilterContext context)
    {
        var versionParameter = operation.Parameters.Single(p => p.Name == "version");
        operation.Parameters.Remove(versionParameter);
    }
}

and

public class ReplaceVersionWithExactValueInPath : IDocumentFilter
{
    public void Apply(SwaggerDocument swaggerDoc, DocumentFilterContext context)
    {
        swaggerDoc.Paths = swaggerDoc.Paths
            .ToDictionary(
                path => path.Key.Replace("v{version}", swaggerDoc.Info.Version),
                path => path.Value
            );
    }
}

Both classes a types of special filters that Swashbuckle provides to aid Swagger generation. RemoveVersionFromParameter will remove the API Version as a parameter from the Swagger document. ReplaceVersionWithExactValueInPath will change the path from being variable api/v{version:apiVersion}/[controller] to having a fixed path e.g. api/v1/[controller] .

Finally, the filters will need to be applied to the Swagger generation in Startup.cs, and some other minor modifications need to be made:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddApiVersioning();

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

    services.AddSwaggerGen(options =>
    {
        options.SwaggerDoc("v1",
            new Info
            {
                Version = "v1",
                Title = "v1 API",
                Description = "v1 API Description",
                TermsOfService = "Terms of usage v1"
            });

        // Add a SwaggerDoc for v2 
        options.SwaggerDoc("v2",
            new Info
            {
                Version = "v2",
                Title = "v2 API",
                Description = "v2 API Description",
                TermsOfService = "Terms of usage v3"
            });

                // Apply the filters
        options.OperationFilter<RemoveVersionFromParameter>();
        options.DocumentFilter<ReplaceVersionWithExactValueInPath>();

                // Ensure the routes are added to the right Swagger doc
        options.DocInclusionPredicate((version, desc) =>
        {
            var versions = desc.ControllerAttributes()
                .OfType<ApiVersionAttribute>()
                .SelectMany(attr => attr.Versions);

            var maps = desc.ActionAttributes()
                .OfType<MapToApiVersionAttribute>()
                .SelectMany(attr => attr.Versions)
                .ToArray();

            return versions.Any(v => $"v{v.ToString()}" == version)
                          && (!maps.Any() || maps.Any(v => $"v{v.ToString()}" == version));;
        });

    });

}

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    loggerFactory.AddConsole(Configuration.GetSection("Logging"));
    loggerFactory.AddDebug();

    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint($"/swagger/v1/swagger.json", $"v1");
                // Specify and endpoint for v2
        c.SwaggerEndpoint($"/swagger/v2/swagger.json", $"v2");
    });
    app.UseMvc();
}

Most of what was done is quite self-explanatory. The only part that might be of interest is the options.DocInclusionPredicate. The little bit of code defining this predicate finds the versions attributed to the controller and the version mappings associated with a particular endpoint, and ensures that the endpoint is included in the correct Swagger doc.

If everything has been done correctly, running the application should now launch a Swagger doc with multiple versions,

Alt Text

And routes for each version:

Alt Text
Alt Text

Posted on by:

htissink profile

Henrick Tissink

@htissink

Professional Pancake Flipper and sometimes writer of code. I make the bug; I fix the bug. C#, JS, Swift and Python <3.

Discussion

markdown guide
 

Hi Henrick,

isn't the solution a bit overly complex?

With .Net Core 2.2 I just did:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v2/swagger.json", "API v2");
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1");
        c.DisplayOperationId();
        c.DisplayRequestDuration();
    });
}

private class ApiExplorerGroupPerVersionConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        var controllerNamespace = controller.ControllerType.Namespace; // e.g. "Controllers.v1"
        var apiVersion = controllerNamespace?.Split('.').Last().ToLower();

        controller.ApiExplorer.GroupName = apiVersion;
    }
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(c =>
        c.Conventions.Add(
            new ApiExplorerGroupPerVersionConvention()) // decorate Controllers to distinguish SwaggerDoc (v1, v2, etc.)
        });

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new Info { Title = "API v1", Version = "v1" });
        c.SwaggerDoc("v2", new Info { Title = "API v2", Version = "v2" });
        c.ExampleFilters();

        // Set the comments path for the Swagger JSON and UI.
        var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
        var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
        c.IncludeXmlComments(xmlPath);
        // Uses full schema names to avoid v1/v2/v3 schema collisions
        // see: https://github.com/domaindrivendev/Swashbuckle/issues/442
        c.CustomSchemaIds(x => x.FullName);
    });
}

 

That is extremely clean and simple. Thanks for showing me this - I was trying to figure out a better way to do this. Using proper namespacing with ApiExplorerGroupPerVersionConvention makes a big difference.

 

You're welcome. I also spent some time trying to figure it out and this was the cleanest solution. Enjoy!

Hello slavius, I have this implementation in an API, and without a doubt it is the simplest and cleanest thing there is. I have only identified one problem. the repetition of the endpoint code from version 1 in version 2. I have solved this by declaring my endpoints in an abstract class and inheriting from it in my controller version 1 and version 2. overwriting functionality of any of those at point if necessary and adding new ones. The problem that I keep seeing is that I will not always want all the endpoints of my version 1 to continue in version 2 of my controller. any suggestion for me

Hi argenis96,

this seems to me more as an architectural problem. The controller should only contain domain isolated code required for it to work. All other code should be extracted to appropriate locations (repository, services, shared library) and be re-used in both controllers.

Have a look at the code from identical endpoints in v1 and v2 and try to extract as much as you can outside of the method as functions leaving only the logic. Don't forget to use interfaces for dependencies so you get good testability. Then your code should remain clean from duplicates and easy to maintain and test.

For example, if your controller queries data from a database but v2 of the same controller method uses more fields returned, you can create a function inside your repository to either accept LINQ expression with output fileds or even Where() clause or you can implement method that returns IQueryable() and materialize it inside the controller by defining SELECT columns and calling .ToList() or .ToArray().

 

I speak too soon, I'm having a problem calling the different versions. How do I decorate my controller to make them unique? I have something like the below but that throw an endpoints.MapControllers() error.

namespace API.Controllers.v1
{
[Route("api/v1/[controller]")]

 

This was awesome. And you can use whatever you want to mark the versions (attributes, namespaces, class names). I like it.

 

Thank you, this is clean and nice and work for me in .net core 3.0.

 

This is a great tutorial. Thanks.

I did get stuck at some point.

With .NET Core 3.1, Swashbuckle has changed a number of things. The "Apply" method in the class that implements the IDocumentFilter should be updated to this:

public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
        {
            var toReplaceWith = new OpenApiPaths();

            foreach (var (key, value) in swaggerDoc.Paths)
            {
                toReplaceWith.Add(key.Replace("v{version}", swaggerDoc.Info.Version, StringComparison.InvariantCulture), value);
            }

            swaggerDoc.Paths = toReplaceWith;
        }

Also, the predicate that ensures endpoints are displayed in their appropriate swagger doc should be updated:

setup.DocInclusionPredicate((version, desc) => 
                {
                    if (!desc.TryGetMethodInfo(out MethodInfo methodInfo))
                        return false;

                    var versions = methodInfo.DeclaringType
                    .GetCustomAttributes(true)
                    .OfType<ApiVersionAttribute>()
                    .SelectMany(attr => attr.Versions);

                    var maps = methodInfo
                    .GetCustomAttributes(true)
                    .OfType<MapToApiVersionAttribute>()
                    .SelectMany(attr => attr.Versions)
                    .ToArray();

                    return versions.Any(v => $"v{v.ToString()}" == version)
                    && (!maps.Any() || maps.Any(v => $"v{v.ToString()}" == version));
                });
 

hi Henrik .thankful for article.
i have problem with Swashbuckle.AspNetCore version 5.0.0-rc4
when i want implement ReplaceVersionWithExactValueInPath class
i get error.
tnx you.

 

Hi!!!

I follow allll the steps but i have this Error : "System.ArgumentException: 'An item with the same key has already been added. Key: v1'"

Can u help me???