API versioning in ASP.NET Core (with nice swagger based docs)


Posted on Apr 15, 2017, 2:27:15 PM

Version the API

There are several ways to version a RESTful API. Scott Hanselman has a nice overview of some of the ways to do it in .NET using https://github.com/Microsoft/aspnet-api-versioning. The code in this post will be using that library for the API versioning.

I'm going to use media types to specify the API version. This is a preference and you can choose another way if it suites you better.


Setting up the versioning is simple

In Startup.cs

public void ConfigureServices(IServiceCollection services)
{
  ...
  services.AddApiVersioning(o =>
  {
    o.DefaultApiVersion = new ApiVersion(1, 0); // specify the default api version
    o.AssumeDefaultVersionWhenUnspecified = true; // assume that the caller wants the default version if they don't specify
    o.ApiVersionReader = new MediaTypeApiVersionReader(); // read the version number from the accept header
  });
  ...
}

Then in your controllers you simply need to add the ApiVersion attribute to your class and optionally the MapToApiVersion to your methods. For example

[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("1.1")]
[Route("api/values")]
[Produces("application/json")]
public class ValuesController : Controller
{
  [HttpGet]
  [MapToApiVersion("1.0")]
  public IEnumerable<string> GetV1(string query)
  {
    return new int[] { 1, 2, 3 }.Select(i => $"{query}-{i}");
  }

  [HttpGet]
  [MapToApiVersion("1.1")]
  public V1_1ViewModel GetV1_1(string query)
  {
    return new V1_1ViewModel {
      Result = new int[] { 1, 2, 3 }.Select(i => $"{query}-{i}")
    };
  }
  [HttpGet("all")]
  public IEnumerable<string> GetAll(string query)
  {
    return new int[] { 1, 2, 3 }.Select(i => $"{query}-{i}");
  }
}

And that's it. Your API is now versioned.

Enter Swashbuckle

Now that your API is ready you'll want some documentation for it. Swashbuckle is a swagger generator for .NET. With it you'll get some nicely generated docs, but it does require a bit of work to get everything playing nicely. The Swashbuckle README does a nice job of explaining how to get it setup so I won't repeat that here. Instead I'll assume you've got the basics setup and now want to have multiple versions showing nicely on your documentation page

In your Startup.cs you'll have something like the below to start with

public void ConfigureServices(IServiceCollection services)
{
  ...
  services.AddSwaggerGen(c =>
  {
    c.SwaggerDoc("v1.0", new Info { Title = "Versioned Api v1.0", Version = "v1.0" });
  }
  ...
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
  ...
  app.UseSwagger();
  app.UseSwaggerUI(c =>
  {
    c.SwaggerEndpoint("/swagger/v1.0/swagger.json", "Versioned Api v1.0");
  });
  ...
}

But this shows all endpoints regardless of version on the same doc page. That isn't terrible but isn't really what we want.

Before we go any further let's create a helper class that'll get used later on

public static class ActionDescriptorExtensions
{
  public static ApiVersionModel GetApiVersion(this ActionDescriptor actionDescriptor)
  {
    return actionDescriptor?.Properties
      .Where((kvp) => ((Type)kvp.Key).Equals(typeof(ApiVersionModel)))
      .Select(kvp => kvp.Value as ApiVersionModel).FirstOrDefault();
  }
}

The next changes ensure that we have two swagger doc pages created and ensure that the correct methods are shown on the correct docs.

public void ConfigureServices(IServiceCollection services)
{
  ...
  services.AddSwaggerGen(c =>
  {
    c.SwaggerDoc("v1.1", new Info { Title = "Versioned Api v1.1", Version = "v1.1" });
    c.SwaggerDoc("v1.0", new Info { Title = "Versioned Api v1.0", Version = "v1.0" });
    c.DocInclusionPredicate((docName, apiDesc) =>
    {
      var actionApiVersionModel = apiDesc.ActionDescriptor?.GetApiVersion();
      // would mean this action is unversioned and should be included everywhere
      if (actionApiVersionModel == null)
      {
        return true;
      }
      if (actionApiVersionModel.DeclaredApiVersions.Any())
      {
        return actionApiVersionModel.DeclaredApiVersions.Any(v => $"v{v.ToString()}" == docName);
      }
      return actionApiVersionModel.ImplementedApiVersions.Any(v => $"v{v.ToString()}" == docName);
    });
  }
  ...
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
  ...
  app.UseSwagger();
  app.UseSwaggerUI(c =>
  {
    c.SwaggerEndpoint("/swagger/v1.1/swagger.json", "Versioned Api v1.1");
    c.SwaggerEndpoint("/swagger/v1.0/swagger.json", "Versioned Api v1.0");
  });
  ...
}

Awesome! But there's still one thing missing. You'll notice that if you run this now the Accept header that the swagger doc page uses does not contain the version number so you'll always get the default version. To fix this we'll use something called a document filter. This is a class that modifies the entire swagger document once Swashbuckle has generated it.

public class ApiVersionOperationFilter : IOperationFilter
{
  public void Apply(Operation operation, OperationFilterContext context)
  {
    var actionApiVersionModel = context.ApiDescription.ActionDescriptor?.GetApiVersion();
    if (actionApiVersionModel == null)
    {
      return;
    }

    if (actionApiVersionModel.DeclaredApiVersions.Any())
    {
      operation.Produces = operation.Produces
        .SelectMany(p => actionApiVersionModel.DeclaredApiVersions
          .Select(version => $"{p};v={version.ToString()}")).ToList();
    }
    else
    {
      operation.Produces = operation.Produces
        .SelectMany(p => actionApiVersionModel.ImplementedApiVersions.OrderByDescending(v => v)
          .Select(version => $"{p};v={version.ToString()}")).ToList();
    }
  }
}

Then we need to tell Swashbuckle about it

In Startup.cs

public void ConfigureServices(IServiceCollection services)
{
  ...
  services.AddSwaggerGen(c =>
  {
    ...
    c.OperationFilter<ApiVersionOperationFilter>();
  }
  ...
}

Now the correct Accept header will be generated by Swashbuckle

I've uploaded a working example uploaded to Github if you want to see a full running application

If you want to know more, I'm @theotherjimi on Twitter