Versioning an ASP.NET Core API

 
 
  • Gérald Barré

API versioning is a way to conform with the Postel's law. Jon Postel wrote this law in an early specification of TCP:

Be conservative in what you do, be liberal in what you accept from others

Jon Postel

This means that you must be conservative in what you send, be liberal in what you accept. Once you have published a version of your API, you cannot change the format of the data it sends to the clients. Adding a new property in a JSON payload or pretty formatting the output may be a breaking change. If you want to change the output of your API, you need to use versioning.

#Multiple ways to version an API

There are multiple ways to version an API. Here're the most common ones:

  • Creating a new route

    // v1
    GET https://example.com/api/weatherforecast
    
    // v2
    GET https://example.com/api/weatherforecast2
  • Adding the version in the query string

    // v1
    GET https://example.com/api/weatherforecast?api-version=1.0
    
    // v2
    GET https://example.com/api/weatherforecast?api-version=2.0
  • Adding the version in the header

    // v1
    GET https://example.com/api/weatherforecast
    X-API-VERSION: 1.0
    
    // v2
    GET https://example.com/api/weatherforecast
    X-API-VERSION: 1.0
  • Adding the version in the header Accept

    // v1
    GET https://example.com/api/weatherforecast
    Accept: application/json;v=1.0
    
    // v2
    GET https://example.com/api/weatherforecast
    Accept: application/json;v=2.0
  • Using the request path to define the version

    // v1
    GET https://example.com/api/v1.0/weatherforecast
    
    // v2
    GET https://example.com/api/v2.0/weatherforecast

#Versioning in ASP.NET Core

Microsoft has developed a ready to use NuGet package to support versioning. It supports most of the versioning schema defined in the previous section out of the box. It is extensible if you need a custom way to define the version.

  1. Install the package Microsoft.AspNetCore.Mvc.Versioning:

    csproj (MSBuild project file)
    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="4.1.1" />
    </ItemGroup>
  2. Add the API versioning services:

    C#
        public class Startup
        {
            public void ConfigureServices(IServiceCollection services)
            {
                services.AddControllers();
    
                // Documentation: https://github.com/microsoft/aspnet-api-versioning/wiki/API-Versioning-Options
                services.AddApiVersioning(options =>
                {
                    // Add the headers "api-supported-versions" and "api-deprecated-versions"
                    // This is better for discoverability
                    options.ReportApiVersions = true;
    
                    // AssumeDefaultVersionWhenUnspecified should only be enabled when supporting legacy services that did not previously
                    // support API versioning. Forcing existing clients to specify an explicit API version for an
                    // existing service introduces a breaking change. Conceptually, clients in this situation are
                    // bound to some API version of a service, but they don't know what it is and never explicit request it.
                    options.AssumeDefaultVersionWhenUnspecified = true;
                    options.DefaultApiVersion = new ApiVersion(2, 0);
    
                    // Defines how an API version is read from the current HTTP request
                    options.ApiVersionReader = ApiVersionReader.Combine(
                        new QueryStringApiVersionReader("api-version"),
                        new HeaderApiVersionReader("api-version"));
                });
            }
  3. Modify the controller to specify the version:

    C#
    using Microsoft.AspNetCore.Mvc;
    
    namespace WebApplication1.Controllers
    {
        [ApiController]
        [Route("HelloWorld")]
        [ApiVersion("1.0", Deprecated = true)]
        public class HelloWorld1Controller : ControllerBase
        {
            [HttpGet]
            public string Get() => "v1.0";
        }
    
        [ApiController]
        [Route("HelloWorld")]
        [ApiVersion("2.0")]
        public class HelloWorld2Controller : ControllerBase
        {
            [HttpGet]
            public string Get() => "v2.0";
        }
    }

You can now query the url https://localhost:44316/helloworld?api-version=2.0 and check the result:

In the previous example, I use one controller per version. If the controller has multiple methods, you may not want to duplicate the whole controller. Instead, you can only add the new method and decorate it with [MapToApiVersion("")]:

C#
// 👇 Declare both versions
[ApiVersion("2.0")]
[ApiVersion("2.1")]
[ApiController, Route("HelloWorld")]
public class HelloWorld2Controller : ControllerBase
{
    // Common to v2.0 and v2.1
    // You can use HttpContext.GetRequestedApiVersion to get the matched version
    [HttpPost]
    public string Post() => "v" + HttpContext.GetRequestedApiVersion();

    // 👇 Map to v2.0
    [HttpGet, MapToApiVersion("2.0")]
    public string Get() => "v2.0";

    // 👇 Map to v2.1
    [HttpGet, MapToApiVersion("2.1")]
    public string Get2_1() => "v2.1";
}

In the previous example, the client can use the query string or a specific header to specify the API version. If you want to use the path, such as https://example.com/api/v2.0/helloworld, you need to change the route:

C#
// Will match "/v1.0/HelloWorld" and "/HelloWorld?api-version=1.0"
[ApiController]
[Route("HelloWorld")] // Support query string / header versioning
[Route("v{version:apiVersion}/HelloWorld")] // Support path versioning
[ApiVersion("1.0")]
public class HelloWorld1Controller : ControllerBase
{
    public string Get() => "v1.0";
}

#Integration with OpenAPI Specification (Swagger)

As you have multiple versions of the API, you should have multiple versions of the swagger file. The code is copied from https://github.com/microsoft/aspnet-api-versioning/tree/master/samples/aspnetcore/SwaggerSample

  1. Add the NuGet packages Swashbuckle.AspNetCore and Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer:

    csproj (MSBuild project file)
    <ItemGroup>
        <PackageReference Include="Swashbuckle.AspNetCore" Version="5.4.1" />
        <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="4.1.1" />
        <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="4.1.1" />
    </ItemGroup>
  2. Edit the startup.cs file to configure Swagger

C#
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();

        services.AddApiVersioning(options =>
        {
            options.DefaultApiVersion = new ApiVersion(2, 0);
            options.AssumeDefaultVersionWhenUnspecified = true;
            options.ReportApiVersions = true;
            options.ApiVersionReader = ApiVersionReader.Combine(
                new QueryStringApiVersionReader("api-version"),
                new HeaderApiVersionReader("api-version"));
        });

        services.AddVersionedApiExplorer(options =>
        {
                // add the versioned api explorer, which also adds IApiVersionDescriptionProvider service
                // note: the specified format code will format the version as "'v'major[.minor][-status]"
                options.GroupNameFormat = "'v'VVV";

                // note: this option is only necessary when versioning by url segment. the SubstitutionFormat
                // can also be used to control the format of the API version in route templates
                options.SubstituteApiVersionInUrl = true;
        });
        services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
        services.AddSwaggerGen(options => options.OperationFilter<SwaggerDefaultValues>());

    }

    // 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.UseSwagger();
        app.UseSwaggerUI(
            options =>
            {
                // build a swagger endpoint for each discovered API version
                foreach (var description in provider.ApiVersionDescriptions)
                {
                    options.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant());
                }
            });

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

public class SwaggerDefaultValues : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        var apiDescription = context.ApiDescription;
        operation.Deprecated |= apiDescription.IsDeprecated();

        if (operation.Parameters == null)
            return;

        // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412
        // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413
        foreach (var parameter in operation.Parameters)
        {
            var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name);
            if (parameter.Description == null)
            {
                parameter.Description = description.ModelMetadata?.Description;
            }

            if (parameter.Schema.Default == null && description.DefaultValue != null)
            {
                parameter.Schema.Default = new OpenApiString(description.DefaultValue.ToString());
            }

            parameter.Required |= description.IsRequired;
        }
    }
}

public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
{
    private readonly IApiVersionDescriptionProvider _provider;

    public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) => _provider = provider;

    public void Configure(SwaggerGenOptions options)
    {
        // add a swagger document for each discovered API version
        // note: you might choose to skip or document deprecated API versions differently
        foreach (var description in _provider.ApiVersionDescriptions)
        {
            options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description));
        }
    }

    private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description)
    {
        var info = new OpenApiInfo()
        {
            Title = "Sample API",
            Version = description.ApiVersion.ToString(),
        };

        if (description.IsDeprecated)
        {
            info.Description += " This API version has been deprecated.";
        }

        return info;
    }
}

You can then get the generated swagger files at:

https://example.com/swagger/v1/swagger.json
https://example.com/swagger/v2/swagger.json
https://example.com/swagger/v2.1/swagger.json

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub