← Programming
ProgrammingPracticing

API Versioning — URL segments + multi-version Swagger

Implemented URL-segment API versioning in my project with a Swagger dropdown per version.

api-versioningswagger

What I set up

URL segment versioning (/api/v{version}/...) using Asp.Versioning, with Swashbuckle generating a separate Swagger document per version so the UI has a dropdown to switch between them.

Packages

<PackageReference Include="Asp.Versioning.Mvc" Version="10.2.1" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.3" />

Program.cs — core versioning setup

builder.Services.AddApiVersioning(opt =>
{
    opt.ReportApiVersions = true;
    opt.AssumeDefaultVersionWhenUnspecified = true;
    opt.DefaultApiVersion = new ApiVersion(1, 0);
    opt.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddMvc().AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
    options.DefaultApiVersion = new ApiVersion(1, 0);
});

What each option actually does:

Generating a Swagger doc per version

public class SwaggerExtension : IConfigureNamedOptions<SwaggerGenOptions>
{
    private readonly IApiVersionDescriptionProvider provider;
 
    public SwaggerExtension(IApiVersionDescriptionProvider provider)
    {
        this.provider = provider;
    }
 
    public void Configure(string? name, SwaggerGenOptions options) => Configure(options);
 
    public void Configure(SwaggerGenOptions options)
    {
        options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
        {
            Name = "Authorization",
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            In = ParameterLocation.Header,
        });
 
        // one Swagger document per discovered API version
        foreach (var description in provider.ApiVersionDescriptions)
        {
            options.SwaggerDoc(description.GroupName, CreateVersionInfo(description));
        }
    }
 
    private OpenApiInfo CreateVersionInfo(ApiVersionDescription description)
    {
        var info = new OpenApiInfo
        {
            Title = "API " + description.GroupName,
            Version = description.ApiVersion.ToString()
        };
        if (description.IsDeprecated)
        {
            info.Description += " This API version has been deprecated.";
        }
        return info;
    }
}

IApiVersionDescriptionProvider is what makes this dynamic — it discovers every version actually declared across your controllers via [ApiVersion] attributes, so you don't hand-maintain a list of versions in two places.

Register it:

builder.Services.ConfigureOptions<SwaggerExtension>();

Wiring the dropdown into Swagger UI

var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
 
app.UseSwaggerUI(options =>
{
    foreach (var description in provider.ApiVersionDescriptions)
    {
        options.SwaggerEndpoint(
            $"/swagger/{description.GroupName}/swagger.json",
            description.GroupName.ToUpperInvariant()
        );
    }
});

This is what produces the version dropdown in the top-right of the Swagger page — one endpoint registered per discovered version.

Using it on a controller

[ApiController]
[Route("api/v{version:apiVersion}/auth")]
[ApiVersion(1.0, Deprecated = true)]
[ApiVersion(2.0)]
public class AuthController : ControllerBase
{
    [HttpPost("register"), MapToApiVersion("2.0")]
    public async Task<IActionResult> Register(...) { ... }
 
    [HttpPost("verify-email"), MapToApiVersion("1.0")]
    public async Task<IActionResult> VerifyEmail(...) { ... }
}

What I learned about MapToApiVersion: declaring [ApiVersion(1.0)] and [ApiVersion(2.0)] on the controller means both versions are valid for this route, but each individual action only responds to the version(s) it's explicitly mapped to. So v1 has verify-email but not register, and v2 has register but not verify-email — hitting register under /api/v1/ correctly 404s, since that endpoint was never mapped to v1.

Swagger UI

Sources & further reading