Skip to main content

ELL Blog

ASP.NET Core Add Versioning

Introduction

Applicable .NET versions: .NET 9, .NET 8. Starting in .NET 9, there are two ways to add versioning. .NET 9 uses OpenAPI & Scaler and requires hardcoding the version in one file, whereas .NET 8 does not need to do this, but does use Swagger. For the .NET 9 tutorial, I will also provide a list of packages I have installed in my project in case your project is missing an unnecessary package.

This tutorial will save you 5 hours. I don’t know when I started adding API version support to my ASP.NET project, but I can tell you that the current documentation is atrocious if you strive for perfection like I do.

As our mobile application is about to have an official MVP release, I wanted to fix the Stripe payment intent method “invoice.” Currently, the invoice method would only return the client secret, but the official docs suggest to return the customer ID and a ephemeral key in addition to the payment intent client secret. I had two options. Either break current builds in use because you know its only beta testing anyways, or I can use this as an opportunity to add API versioning. There’s two main ways to do API versioning in my eyes. The first way, which is what I thought I would’ve added in the future, is to be one of those companies with apis like /v2/my-api but then I found out about header APIs where the client can just add a header. This saves a lot of time because first of all, most of my apis were /api not /v1/api and the client was already calling without a version. I want an easy way to just switch the default version to v2 so I went with reading the API version from the header.

After hours of consulting Gemini, the official docs, and examples, and personal touches, I present the absolutely bare minimum way to add versioning to your ASP.NET application.

NuGet Packages

In .NET 9, I needed the following Asp.Versioning packages

  • Asp.Versioning.Http
  • Asp.Versioning.Http.Client
  • Asp.Versioning.Mvc.ApiExplorer

You can either use VSCode’s C# Dev Kit, Visual Studio’s Nuget Packages UI, or the dotnet CLI to install these packages. For example, dotnet add <PROJECT> package <PACKAGE_NAME>

If you’re using Visual Studio, you could also run nuget install Asp.Versioning.Mvc.ApiExplorer

.NET 9 Versioning

In .NET 9, we no longer need a ProgramAuxiliary.cs.

OpenApi.Extensions.cs
using Asp.Versioning;
using Scalar.AspNetCore;

namespace SttApi;

public static partial class Extensions {
    public static IApplicationBuilder UseDefaultOpenApi(this WebApplication app) {
        var configuration = app.Configuration;

        if (app.Environment.IsDevelopment()) {
            app.MapOpenApi()
                .CacheOutput();
            app.MapScalarApiReference(options => {
                // Disable default fonts to avoid download unnecessary fonts
                options.DefaultFonts = false;
                options.Title = "Split The Tank API Reference";
                options.EnabledClients = [ScalarClient.Fetch, ScalarClient.HttpClient, ScalarClient.Nsurlsession, ScalarClient.OkHttp];
                // TODO: add default berar
                // TODO: order actions by
                //     options.OrderActionsBy(apiDesc => {
                //         var priority = apiDesc.ActionDescriptor.RouteValues["controller"]!.Contains("Debug") ? "_" : "";
                //         return $"{priority}{apiDesc.ActionDescriptor.RouteValues["controller"]}_{apiDesc.HttpMethod}";
                //     });
                // .WithHttpBearerAuthentication(bearer => bearer.Token = "");
            });
            app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription();
        }
        return app;
    }

    public static IHostApplicationBuilder AddDefaultOpenApi(
        this IHostApplicationBuilder builder,
        IApiVersioningBuilder? apiVersioning = default) {
        var openApi = builder.Configuration.GetSection("OpenApi");
        var identitySection = builder.Configuration.GetSection("Identity");

        var scopes = identitySection.Exists()
            ? identitySection.GetRequiredSection("Scopes").GetChildren().ToDictionary(p => p.Key, p => p.Value)
            : new Dictionary<string, string?>();

        if (apiVersioning is not null) {
            // the default format will just be ApiVersion.ToString(); for example, 1.0.
            // this will format the version as "'v'major[.minor][-status]"
            var versioned = apiVersioning.AddApiExplorer(options => options.GroupNameFormat = "'v'VVV");
            // Search code base for [ApiVersion(#.0)]
            string[] versions = ["v1", "v2"];
            foreach (var description in versions) {
                builder.Services.AddOpenApi(description, options => {
                    if (openApi.Exists()) {
                        options.ApplyApiVersionInfo(openApi.GetRequiredValue("Document:Title"), openApi.GetRequiredValue("Document:Description"));
                    }
                    options.ApplyAuthorizationChecks([.. scopes.Keys]);
                    options.ApplySecuritySchemeDefinitions();
                    options.ApplyOperationDeprecatedStatus();
                    options.ApplyApiVersionDescription();
                    options.ApplySchemaNullableFalse();
                    // Clear out the default servers so we can fallback to
                    // whatever ports have been allocated for the service by Aspire
                    options.AddDocumentTransformer((document, context, cancellationToken) => {
                        document.Servers = [];
                        return Task.CompletedTask;
                    });
                });
            }
        }

        return builder;
    }
}

This next file also includes Bearer Authentication information which is used by Scalar.

OpenApiOptionsExtensions.cs
using System.Text;
using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;

namespace SttApi;

internal static class OpenApiOptionsExtensions
{
    public static OpenApiOptions ApplyApiVersionInfo(this OpenApiOptions options, string title, string description)
    {
        options.AddDocumentTransformer((document, context, cancellationToken) =>
        {
            var versionedDescriptionProvider = context.ApplicationServices.GetService<IApiVersionDescriptionProvider>();
            var apiDescription = versionedDescriptionProvider?.ApiVersionDescriptions
                .SingleOrDefault(description => description.GroupName == context.DocumentName);
            if (apiDescription is null)
            {
                return Task.CompletedTask;
            }
            document.Info.Version = apiDescription.ApiVersion.ToString();
            document.Info.Title = title;
            document.Info.Description = BuildDescription(apiDescription, description);
            return Task.CompletedTask;
        });
        return options;
    }

    private static string BuildDescription(ApiVersionDescription api, string description)
    {
        var text = new StringBuilder(description);

        if (api.IsDeprecated)
        {
            if (text.Length > 0)
            {
                if (text[^1] != '.')
                {
                    text.Append('.');
                }

                text.Append(' ');
            }

            text.Append("This API version has been deprecated.");
        }

        if (api.SunsetPolicy is { } policy)
        {
            if (policy.Date is { } when)
            {
                if (text.Length > 0)
                {
                    text.Append(' ');
                }

                text.Append("The API will be sunset on ")
                    .Append(when.Date.ToShortDateString())
                    .Append('.');
            }

            if (policy.HasLinks)
            {
                text.AppendLine();

                var rendered = false;

                foreach (var link in policy.Links.Where(l => l.Type == "text/html"))
                {
                    if (!rendered)
                    {
                        text.Append("<h4>Links</h4><ul>");
                        rendered = true;
                    }

                    text.Append("<li><a href=\"");
                    text.Append(link.LinkTarget.OriginalString);
                    text.Append("\">");
                    text.Append(
                        StringSegment.IsNullOrEmpty(link.Title)
                        ? link.LinkTarget.OriginalString
                        : link.Title.ToString());
                    text.Append("</a></li>");
                }

                if (rendered)
                {
                    text.Append("</ul>");
                }
            }
        }

        return text.ToString();
    }

    public static OpenApiOptions ApplySecuritySchemeDefinitions(this OpenApiOptions options)
    {
        options.AddDocumentTransformer<SecuritySchemeDefinitionsTransformer>();
        return options;
    }

    public static OpenApiOptions ApplyAuthorizationChecks(this OpenApiOptions options, string[] scopes)
    {
        options.AddOperationTransformer((operation, context, cancellationToken) =>
        {
            var metadata = context.Description.ActionDescriptor.EndpointMetadata;

            if (!metadata.OfType<IAuthorizeData>().Any())
            {
                return Task.CompletedTask;
            }

            operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
            operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });

            var oAuthScheme = new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" }
            };

            operation.Security = new List<OpenApiSecurityRequirement>
            {
                new()
                {
                    [oAuthScheme] = scopes
                }
            };

            return Task.CompletedTask;
        });
        return options;
    }

    public static OpenApiOptions ApplyOperationDeprecatedStatus(this OpenApiOptions options)
    {
        options.AddOperationTransformer((operation, context, cancellationToken) =>
        {
            var apiDescription = context.Description;
            operation.Deprecated |= apiDescription.IsDeprecated();
            return Task.CompletedTask;
        });
        return options;
    }

    public static OpenApiOptions ApplyApiVersionDescription(this OpenApiOptions options)
    {
        options.AddOperationTransformer((operation, context, cancellationToken) =>
        {
            // Find parameter named "api-version" and add a description to it
            var apiVersionParameter = operation.Parameters.FirstOrDefault(p => p.Name == "api-version");
            if (apiVersionParameter is not null) {
                apiVersionParameter.Description = "The API version, in the format 'major.minor'.";
                var versionNumber = context.DocumentName.TrimStart('v');
                if (int.TryParse(versionNumber, out var version)) {
                    apiVersionParameter.Schema.Example = new OpenApiString($"{version}.0");
                } else {
                    throw new ArgumentException("got invalid document name {context.DocumentName}. Expected format v#");
                }
            }
            return Task.CompletedTask;
        });
        return options;
    }

    // This extension method adds a schema transformer that sets "nullable" to false for all optional properties.
    public static OpenApiOptions ApplySchemaNullableFalse(this OpenApiOptions options)
    {
        options.AddSchemaTransformer((schema, context, cancellationToken) =>
        {
            if (schema.Properties is not null)
            {
                foreach (var property in schema.Properties)
                {
                    if (schema.Required?.Contains(property.Key) != true)
                    {
                        property.Value.Nullable = false;
                    }
                }
            }

            return Task.CompletedTask;
        });
        return options;
    }

    private class SecuritySchemeDefinitionsTransformer(IConfiguration configuration) : IOpenApiDocumentTransformer
    {
        public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
        {
            document.Components ??= new();
            document.Components.SecuritySchemes.Add("Bearer", new OpenApiSecurityScheme {
                            In = ParameterLocation.Header,
                            Description = "Please enter a valid token",
                            Name = "Authorization",
                            Type = SecuritySchemeType.Http,
                            BearerFormat = "JWT",
                            Scheme = "Bearer"
                        });
            return Task.CompletedTask;
        }
    }
}
Program.cs
var apiVersioning = builder.Services.AddApiVersioning(options => {
            options.DefaultApiVersion = new ApiVersion(1, 0);
            options.AssumeDefaultVersionWhenUnspecified = true;
            options.ApiVersionReader = new HeaderApiVersionReader("x-ms-version");
            options.ReportApiVersions = true;
            options.UnsupportedApiVersionStatusCode = 501;
        });
        builder.AddDefaultOpenApi(apiVersioning);
// after MapControllers
app.UseDefaultOpenApi();
csproj Partial - Packages
<ItemGroup>
    <PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
    <PackageReference Include="Asp.Versioning.Http.Client" Version="8.1.0" />
    <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
    <PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.1" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.1" />
    <PackageReference Include="Microsoft.Azure.AppConfiguration.AspNetCore" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="9.0.1">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.1" />
    <PackageReference Include="Microsoft.FeatureManagement.AspNetCore" Version="4.0.0" />
    <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.3.1" />
    <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
    <PackageReference Include="MongoDB.Analyzer" Version="1.5.0" />
    <PackageReference Include="MongoDB.Driver" Version="3.1.0" />
    <PackageReference Include="Postmark" Version="5.2.0" />
    <PackageReference Include="Scalar.AspNetCore" Version="2.0.9" />
    <PackageReference Include="Stripe.net" Version="47.3.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
    <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.1" />
    <PackageReference Include="System.Text.Json" Version="9.0.1" />
  </ItemGroup>

.NET 8 Versioning

Program.cs

Note that by default, ASP.NET will set each route as version 1 unless otherwise defined. Also note that if a client does not specify a version, the version 1 route will be used by default.

builder.Services.AddApiVersioning(options => {
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ApiVersionReader = new HeaderApiVersionReader("x-ms-version");
    options.ReportApiVersions = true;
    options.UnsupportedApiVersionStatusCode = 501;
})
// format the version as "'v'major[.minor][-status]"
.AddApiExplorer(options => {
    options.GroupNameFormat = "'v'VVV";
});

ProgramAuxiliary.cs

The following code allows the Swagger UI to work with the versioning.

Some of this code might be unused, this is because in my own ProgramAuxiliary.cs, I also have the code to enable kebab case routes.

// ProgramAuxiliary.cs is a supplementary to the startup code in Program.cs where this file contains some boilerplate to provide abstraction
using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace SttApi;

// all I know is that this is related to making swagger work with different versions of the API
public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) : IConfigureOptions<SwaggerGenOptions> {
    readonly IApiVersionDescriptionProvider _provider = provider;
    readonly string _apiName = "Split The Tank";

    public void Configure(SwaggerGenOptions options) {
        foreach (var description in _provider.ApiVersionDescriptions) {
            options.SwaggerDoc(
              description.GroupName,
                new OpenApiInfo() {
                    Title = $"{_apiName} API {description.ApiVersion}",
                    Version = description.ApiVersion.ToString(),
                });
        }
    }
}

// All I know is that this bunch of code is related to making sure the API version is set in the header by default
public class SwaggerDefaultValues : IOperationFilter {
    /// <inheritdoc />
    public void Apply(OpenApiOperation operation, OperationFilterContext context) {
        var apiDescription = context.ApiDescription;

        operation.Deprecated |= apiDescription.IsDeprecated();

        // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077
        foreach (var responseType in context.ApiDescription.SupportedResponseTypes) {
            // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387
            var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString();
            var response = operation.Responses[responseKey];

            foreach (var contentType in response.Content.Keys) {
                if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) {
                    response.Content.Remove(contentType);
                }
            }
        }

        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);

            parameter.Description ??= description.ModelMetadata?.Description;

            if (parameter.Schema.Default == null && description.DefaultValue != null &&
                 description.DefaultValue is not DBNull && description.ModelMetadata is ModelMetadata modelMetadata) {
                // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330
                var json = JsonSerializer.Serialize(description.DefaultValue, modelMetadata.ModelType);
                parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json);
            }

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

Controller Example

[Route("[controller]/[action]")]
[ApiController]
[Authorize]
// since we are defining a version 2, we want to inform ASP.NET that the other routes are version 1, you can experiment without this at first just to see the result
[ApiVersion(1.0)]
public class PaymentController : ControllerBase {
  // this is Version 1
  [HttpGet]
  public async Task<ActionResult<string>> Invoice(string payee) {
      // implementation omitted
  }

  [HttpGet]
  [ApiVersion(2.0)]
  [ActionName(nameof(Invoice))]
  public async Task<ActionResult<PaymentSheetProps>> InvoiceV2(string payee) {
      // implementation omitted
  }
}
// gate-keep entire API to reduce possibility of unofficial clients
[Route("[controller]/[action]")]
[ApiController]
[Authorize]
public class CarsController : Controller {
    private readonly CarService _carService;

    public CarsController(CarService carService) {
        _carService = carService;
    }
    // this is implicitly Version 1
    [HttpGet]
    public async Task<List<OwnedCar>> Owned() {
        var cars = await _carService.GetOwned(User.FindFirstValue(ClaimTypes.Email)!);
        return cars!;
    }
}

Client Example

In the future, I will provide samples in Kotlin and Swift as well. For now, here’s a JS implementation. Just use an LLM to get code for the language of the client you are writing.

// note that jwtFetch is just a wrapper around fetch that does auto logging out and .json() conversion when applicable
export async function paymentInvoice(jwt, logout, payee) {
  const response = await jwtFetch(`payment/invoice?payee=${payee}`, logout, {
    method: 'GET',
    headers: { ...buildAuthHeader(jwt), 'x-ms-version': '2.0' },
  });
  return response;
}