API Versioning — URL segments + multi-version Swagger
Implemented URL-segment API versioning in my project with a Swagger dropdown per version.
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:
-
ReportApiVersions— addsapi-supported-versions/api-deprecated-versionsresponse headers, so clients can discover versions programmatically -
UrlSegmentApiVersionReader— reads the version from the URL path (/api/v1/...), matching my earlier decision to go URL-segment over header/query -
GroupNameFormat: "'v'VVV"— controls how versions render in Swagger's grouping (v1,v2) -
SubstituteApiVersionInUrl— replaces{version:apiVersion}in route templates with the actual version when generating Swagger docsTo add multiple method
opt.ApiVersionReader = ApiVersionReader.Combine( new UrlSegmentApiVersionReader(), // 1. URL Path: /api/v1/products new QueryStringApiVersionReader("api-version"), // 2. Query String: ?api-version=1.0 new HeaderApiVersionReader("X-Api-Version"), // 3. Custom Header: X-Api-Version: 1.0 new MediaTypeApiVersionReader("x-api-version") // 4. Accept Header: application/json;x-api-version=1.0 );
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.
