Lots has been written about using Swagger to provide a useful api documentation api and even more about versioning your web apis. Bringing the two together with as little code as possible is now a common ‘boilerplate’ requirement so I wanted to break down the various parts and options available within this area (not least as a reminder to myself!).
The three core components in this recipe are as follows:
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="4.1.1" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="4.1.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="5.0.0" />
The first package provides the options of declaring your api options, including the approach you are using (url segments/ query parameter etc.)
The second package provides self discovery of the version available within your project.
The third is obviously the addition of Swashbuckle to generate our Swagger pages.
The next step is to put these to work. First up is to decide which approach we are going to take in how we determine our versioning. This in itself can cause debates of biblical proportion so I’ll try and cover off the two common approaches. First off, let’s have a look at url segment api versioning:
Routing
Url segment routing involves adding the version into the url path, so where your api route may start off as the controller name (i.e. api/Client), the api version might get inject before this token, i.e. api/v1/Client. Note you don’t have to decorate your api controller route with the ‘api’ prefix – this is often used to distinguish a controller is an API controller and not a view controller.
The pattern for decorating your controller for url segment versioning is as follows:
[Route("v{version:apiVersion}/[controller]")]
The ‘v’ prefix is optional but common-place. Note I have not gone with an ‘api’ prefix in my example.
You will also need to decorate your controller with the actual api version:
[ApiVersion("1.0")]
Back in your startup.cs code, you need to add the mvc services if they ahven’t already been added, then add in the Api Versioning option as well as declaring the format you are using for your routing to allow the versions to be discovered:
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0); services.AddApiVersioning(options => { options.ReportApiVersions = true; options.ApiVersionReader = new UrlSegmentApiVersionReader(); }); services.AddVersionedApiExplorer(options => { options.GroupNameFormat = "'v'VV"; options.SubstituteApiVersionInUrl = true; });
Here I have declared my api version reader as an UrlSegmentApiVersionReader and, within the Api explorer options, declared the format I am using as “‘v’VV” where VV indicates a {Major}.{Minor} format and the ‘v’ is a prefix (i.e. v1.0)
IApiVersionDescriptionProvider
The Api Explorer option can be used through the above provider to locate all of your versions and, coupled with Swagger generation options, you can add a swagger document for each version you have:
services.AddSwaggerGen(options => { // get the api version description provider from the service collection var provider = services.BuildServiceProvider().GetRequiredService<IApiVersionDescriptionProvider>(); foreach (var description in provider.ApiVersionDescriptions) { options.SwaggerDoc( description.GroupName, new OpenApiInfo { Title = "AgileTea Learning API", Version = description.ApiVersion.ToString() }); } });
In the above code, we are stepping through each discovered api version and creating a top level Swagger document for it. I’ll come back to the code smell of building the service provider within the above code later on as this approach creates an extra copy of singleton instances which is not ideal.
Within the Configure method of startup.cs, we do have the option of injecting the IApiVersionDescriptionProvider and can use this here when setting out our options fur using the Swagger UI:
app.UseSwagger(); app.UseSwaggerUI(options => { foreach (var description in provider.ApiVersionDescriptions) { options.SwaggerEndpoint($"../swagger/{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant()); } });
Let’s test this out with a set of versioned controllers. I’ve built out my Client controller from my previous post to include all the expected CRUD operations through an inject service (not implemented). Here’s my Client Contoller (V1) class:
[ApiController] [ApiVersion("1.0")] [Route("v{version:apiVersion}/[controller]")] public class ClientController : ControllerBase { private readonly IClientService clientService; public ClientController(IClientService clientService) { this.clientService = clientService; } [HttpGet] public async Task<ActionResult<IEnumerable<Client>>> GetAsync() { return new ActionResult<IEnumerable<Client>>(await clientService.GetClientsAsync().ConfigureAwait(false)); } [HttpGet("{id}")] public async Task<ActionResult<Client>> GetAsync(Guid id) { return new ActionResult<Client>(await clientService.GetClientAsync(id).ConfigureAwait(false)); } [HttpPost] public async Task<ActionResult> PostAsync([FromBody] Client client) { await clientService.CreateClientAsync(client).ConfigureAwait(false); return Created($"/Client/{client.Id}", client); } [HttpPut] public async Task<ActionResult> PutAsync([FromBody] Client client) { await clientService.UpdateClientAsync(client).ConfigureAwait(false); return Ok(); } [HttpDelete("{id}")] public async Task<ActionResult> DeleteAsync(Guid id) { await clientService.DeleteClientAsync(id).ConfigureAwait(false); return Ok(); } }
I’ve also taken a copy of this and added it to a new V2 namespace and modified the Api Version to 2.0:
You could argue the V1 controller should be in its own V1 namespace/folder herebut I’ve left it as-is for the sake of brevity.
Setting up /swagger as my launch page I end up with the following on debug:
You can see the definition dropdown is automatically populated with the discovered versions and the url examples for all operations are correctly displayed. If I select V2.0 from the dropdown, it shows the following page:
Query Parameter
Another approach is to use a query parameter to determine the version of your api. This involves tagging it at the end of your url segments with the query parameter api-version.
The first change to notice is that you can remove any reference to the api version within the routing attribute so they become a little cleanerL
[Route("[controller]")]
Your api version option for the reader then needs to change to use the query string option:
options.ApiVersionReader = new QueryStringApiVersionReader();
You also don’t need to substitute the api version within the url. rebuild and launch with these changes and you’ll notice the change in the urls:
You still get the version options auto-populating the dropdown and being set correctly on the document title but, when you expand on one of the operations, you will notice that, whilst the api-version parameters has been picked up and identitifed as a query parameter, the default value has not been set to the version for the document, instead just using the default ‘api-verion’ text:
Fortunately there is a way to set this and, whilst we’re at it, remove the need to build the service provider in the middle of our ConfigureServices method.
SwaggerGenOptions
Rather than setting the options for Swagger generation inline, you can create a configuration class that inherits from IConfigureOptions<SwaggerGenOptions> that will be automatically picked up and applied during the set up of your Swagger Generation. You can also inject the IApiVersionDescriptionProvider into this class, removing the need to build your service provider in startup. First off, we can mvoe our start up Swagger gen options into the Configure method of this new options class:
namespace AgileTea.Learning.WebApi.Configuration { internal class SwaggerGenConfigurationOptions : IConfigureOptions<SwaggerGenOptions> { private readonly IApiVersionDescriptionProvider provider; public SwaggerGenConfigurationOptions(IApiVersionDescriptionProvider provider) { this.provider = provider; } public void Configure(SwaggerGenOptions options) { foreach (var description in provider.ApiVersionDescriptions) { options.SwaggerDoc( description.GroupName, new OpenApiInfo { Title = "AgileTea Learning API", Version = description.ApiVersion.ToString() }); } } } }
In your startup code, you can then replace the inline setup with the following two lines of code:
services.AddTransient<IConfigureOptions<SwaggerGenOptions>, SwaggerGenConfigurationOptions>(); services.AddSwaggerGen();
The next part is a little more involved. In order to get the api-version to default to the correct version number for every operation, we need to manipulate the Operation Filter applied by Swashbuckle to each operation within each document.
When you decorate a controller with an ApiVersion attribute, that attribute will apply to all operations (methods) within that controller unless overridden at the Operation level. By looking for the ApiVersionAttribute for each operation and extracting the set version, we can then extract the api-version query parameter that Swashbuckle has set on the operation and sets its value for the examples given within the Swagger document UI:
public class AddApiVersionExampleValueOperationFilter : IOperationFilter { private const string ApiVersionQueryParameter = "api-version"; public void Apply(OpenApiOperation operation, OperationFilterContext context) { var apiVersionParameter = operation.Parameters.SingleOrDefault(p => p.Name == ApiVersionQueryParameter); if (apiVersionParameter == null) { // maybe we should warn the user if they are using this filter without applying the QueryStringApiVersionReader as the ApiVersionReader return; } // get the [ApiVersion("VV")] attribute var attribute = context?.MethodInfo?.DeclaringType? .GetCustomAttributes(typeof(ApiVersionAttribute), false) .Cast<ApiVersionAttribute>() .SingleOrDefault(); // extract the value of the api version var version = attribute?.Versions?.SingleOrDefault()?.ToString(); // may be we should warn if we find un-versioned ApiControllers/ operations? if (version != null) { apiVersionParameter.Example = new OpenApiString(version); apiVersionParameter.Schema.Example = new OpenApiString(version); } } }
I’ve left some comments in to query what we might want to happen should this Query Parameter operation filter be used in a scneario in which either the QueryStringApiVersionReader is not used or we find no api version set on a controller/ operation. These considerations are out of scope for this post but worth attention nonetheless.
It’s the Schema.Example that sets the example value in the Swagger document, however the Example value will override this if set so, to be safe, I’ve set this to be the same.
The final amendment is to set the this new Operation filter type on the Swagger Gen options in our Configure method:
public void Configure(SwaggerGenOptions options) { options.OperationFilter<AddApiVersionExampleValueOperationFilter>(); // rest of code removed for brevity... }
When navigating to our Swagger page, we can now see how this impacts on the examples:
You can grab the code from my github repo which includes example from my previous posts on web api development:
3 Comments
David · 13th August 2021 at 4:42 pm
Hello, SingleOrDefault() seems not working in AddApiVersionExampleValueOperationFilter when you have more than one api version.
Ben · 21st August 2021 at 5:13 pm
Thanks David for catching this – I’ll take a look and update accordingly. Cheers
Gary Mason · 1st October 2021 at 3:45 pm
I just wanted to make you aware, if you’re not already, about the attribute
[MapToApiVersion(“1.0”)] which can be applied on the method. If you use this you don’t need a separate controller for each version and greatly reduces duplication. However from testing it doesn’t seem to work properly if [ApiVersion(“1.0”)] is applied on a base class. It’s useful if you don’t have many changes within the controller. For large scale changes then your implementation of a new controller is certainly a better approach.