Integrating ASP.NET Core Api Versions with Swagger UI

ASP.NET Core Posted 21 days ago

In the previous article, we looked at how to convert our conventional ASP.NET Core API resources to support versions using the Microsoft.AspNetCore.Mvc.Versioning nuget package. The output of the transformation was that we got two API versions, one which is mapped for the path /api/v1/heroes which also maps to the conventional /api/heroes API (for support to older API routes) and a newer /api/v2/heroes which comes with some new functionalities apart from the existing endpoints, which are modified for the new version.

In this article, let's go a bit further and try creating Swagger documentation for these API versions and solve any issues that we encounter.

The controllers at the end of our versioning transformation looked something like this:

## /api/v1/heroes ##
namespace Heroes.Api.Controllers.V1
{
    [ApiController]
    [ApiVersion("1.0")]
    [Route("api/v{version:apiVersion}/[controller]")]
    [Route("api/[controller]")] // for backward compatibility
    public class HeroesController : ControllerBase
    {
        private readonly ILogger<HeroesController> _logger;
        private readonly IHeroesRepository _data;

        public HeroesController(ILogger<HeroesController> logger, IHeroesRepository data)
        {
            _logger = logger;
            _data = data;
        }
        
        
        [HttpGet, Route("alive")]
        public string Alive()
        {
            return "Captain, 1.0 Here. I'm Alive and Kicking!";
        }

        
        [HttpGet]
        public IEnumerable<Hero> Get()
        {
            return _data.All();
        }

        
        [HttpGet, Route("{id}")]
        public Hero Get(int id)
        {
            return _data.Single(x => x.Id == id);
        }

        
        [HttpPost]
        public Hero Post(Hero model)
        {
            var item = new Hero
            {
                Category = model.Category,
                HasCape = model.HasCape,
                IsAlive = model.IsAlive,
                Name = model.Name,
                Powers = model.Powers
            };
            return _data.Create(item);
        }

        
        [HttpGet, Route("searchbyname")]
        public IEnumerable<Hero> SearchByName(string name = "")
        {
            return _data.Search(x => x.Name.Contains(name));
        }
    }
}

## /api/v2/heroes ##
namespace Heroes.Api.Controllers.V2
{
    [ApiController]
    [ApiVersion("2.0")]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class HeroesController : ControllerBase
    {
        private readonly ILogger<HeroesController> _logger;
        private readonly IHeroesRepository _data;

        public HeroesController(ILogger<HeroesController> logger, IHeroesRepository data)
        {
            _logger = logger;
            _data = data;
        }

        
        [HttpGet, Route("alive")]
        public string Alive()
        {
            return "Captain, 2.0 Here. I'm Alive and Kicking!";
        }

        
        [HttpGet]
        public IEnumerable<Hero> Get()
        {
            return _data.All();
        }

        
        [HttpPost]
        public Hero Post(HeroItem model)
        {
            var item = new Hero
            {
                Category = model.Category,
                HasCape = model.HasCape,
                IsAlive = model.IsAlive,
                Name = model.Name,
                Powers = model.Powers
            };
            return _data.Create(item);
        }

        
        [HttpGet, Route("searchbyname")]
        public IEnumerable<Hero> SearchByName(string name = "")
        {
            return _data.Search(x => x.Name.Contains(name));
        }

        
        [HttpGet, Route("categories")]
        public IEnumerable<string> Categories()
        {
            return (string[])Enum.GetNames(typeof(Category));
        }
    }
}

And they're actually working fine:

GET 'https://localhost:5001/api/v1/heroes/alive' 

Captain, 1.0 Here. I'm Alive and Kicking!
GET 'https://localhost:5001/api/v2/heroes/alive' 

Captain, 2.0 Here. I'm Alive and Kicking!

Troubles begin - Adding SwaggerUI:

Now that we have a working set of APIs, let's add a swagger documentation for the same. Going by what we've been doing for any other ASP.NET Core, let's begin by installing the Swashbuckle.AspNetCore nuget and then adding the required services and middlewares.

The startup class looks like below:

namespace Heroes.Api
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddScoped<IHeroesRepository, HeroesRepository>();
            services.AddControllers();

            services.AddApiVersioning(setup =>
            {
                setup.DefaultApiVersion = new ApiVersion(1, 0);
                setup.AssumeDefaultVersionWhenUnspecified = true;
                setup.ReportApiVersions = true;
            });

            services.AddSwaggerGen();
        }

        public void Configure(
            IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();

            app.UseSwagger();
            app.UseSwaggerUI(options =>
            {
                options.SwaggerEndpoint("/swagger/v1/swagger.json", "V1");
            });

            app.UseRouting();

            app.UseAuthorization();

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

Now when we try hitting the swagger UI to see if the APIs are populated, we're greeted with an error message on the UI and an exception in the console.

An unhandled exception has occurred while executing the request.
Swashbuckle.AspNetCore.SwaggerGen.SwaggerGeneratorException: Conflicting method/path combination "GET api/v{version}/Heroes/alive" for actions - Heroes.Api.Controllers.V2.HeroesController.Alive (Heroes.Api),Heroes.Api.Controllers.V1.HeroesController.Alive (Heroes.Api). Actions require a unique method/path combination for Swagger/OpenAPI 3.0.

Troubleshooting and the Road to Solution:

From the error, we can understand that the API Versioning setup we've put forth is not understood by the Swagger while parsing the APIs for their metadata. To solve this, let's begin by marking the endpoints with the information on which version to map to. To do this, we decorate every endpoint with the [MapToApiVersion("")] attribute, which takes one string argument - the version which this endpoint to be mapped against.

For example, our V2 HeroesController becomes like this:

namespace Heroes.Api.Controllers.V2
{
    [ApiController]
    [ApiVersion("2.0")]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class HeroesController : ControllerBase
    {
        public HeroesController(
            ILogger<HeroesController> logger, 
            IHeroesRepository data)
        {
            // code ommitted for brevity
        }
        
        [MapToApiVersion("2.0")]
        [HttpGet, Route("alive")]
        public string Alive()
        {
            // code ommitted for brevity
        }

        [MapToApiVersion("2.0")]
        [HttpGet]
        public IEnumerable<Hero> Get()
        {
            // code ommitted for brevity
        }

        [MapToApiVersion("2.0")]
        [HttpPost]
        public Hero Post(HeroItem model)
        {
            // code ommitted for brevity
        }

        [MapToApiVersion("2.0")]
        [HttpGet, Route("searchbyname")]
        public IEnumerable<Hero> SearchByName(string name = "")
        {
            // code ommitted for brevity
        }

        [MapToApiVersion("2.0")]
        [HttpGet, Route("categories")]
        public IEnumerable<string> Categories()
        {
            // code ommitted for brevity
        }
    }
}

Next, to get the information on these versions and endpoints, we add the Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer nuget package which works on these version information on the endpoints to provide us the metadata.

dotnet add package Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer

Next, add the ApiExplorer service to the collection.

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<IHeroesRepository, HeroesRepository>();
    services.AddControllers();

    services.AddApiVersioning(setup =>
    {
        setup.DefaultApiVersion = new ApiVersion(1, 0);
        setup.AssumeDefaultVersionWhenUnspecified = true;
        setup.ReportApiVersions = true;
    });

    services.AddVersionedApiExplorer(setup =>
    {
        setup.GroupNameFormat = "'v'VVV";
        setup.SubstituteApiVersionInUrl = true;
    });

    services.AddSwaggerGen();
}

And we inject the ApiExplorer service into the Configure method and use the metadata it has collected into the SwaggerUI middleware.

public void Configure(
    IApplicationBuilder app, 
    IWebHostEnvironment env, 
    IApiVersionDescriptionProvider provider)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();

    app.UseSwagger();

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

    app.UseRouting();

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

This actually makes the error disappear, and we're now able to see two Versions in the Swagger version dropdown and the V1 works as well! But when you try look into V2 version, you see a not found error. And actually the swagger json is not generated for V2 APIs (/swagger/v2/swagger.json). How do we solve this?

The answer is, we need to ensure that Swagger creates documentation for all the available versions provided by the ApiExplorer. For a single version, we can create a documentation info inside the SwaggerGen() service. But when we have multiple versions, its not a good way to hardcode documentation info.

Instead, we configure the SwaggerGenOptions by giving it a NamedOptions implementation to substitute it during it runtime.

Why you ask? Because to get the information on the APIs we need to use the ApiExplorer service which we can't inject it inside the ConfigureServices (not a good practice). So this is the way.

The ConfigureSwaggerOptions class looks like below:

namespace Heroes.Api
{
    public class ConfigureSwaggerOptions
        : IConfigureNamedOptions<SwaggerGenOptions>
    {
        private readonly IApiVersionDescriptionProvider provider;

        public ConfigureSwaggerOptions(
            IApiVersionDescriptionProvider provider)
        {
            this.provider = provider;
        }

        public void Configure(SwaggerGenOptions options)
        {
            // add swagger document for every API version discovered
            foreach (var description in provider.ApiVersionDescriptions)
            {
                options.SwaggerDoc(
                    description.GroupName, 
                    CreateVersionInfo(description));
            }
        }

        public void Configure(string name, SwaggerGenOptions options)
        {
            Configure(options);
        }

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

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

            return info;
        }
    }
}

we'd then wire this up to the service collection.

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<IHeroesRepository, HeroesRepository>();
    services.AddControllers();

    services.AddApiVersioning(setup =>
    {
        setup.DefaultApiVersion = new ApiVersion(1, 0);
        setup.AssumeDefaultVersionWhenUnspecified = true;
        setup.ReportApiVersions = true;
    });

    services.AddVersionedApiExplorer(setup =>
    {
        setup.GroupNameFormat = "'v'VVV";
        setup.SubstituteApiVersionInUrl = true;
    });

    services.AddSwaggerGen();
    services.ConfigureOptions<ConfigureSwaggerOptions>();
}

And when we try loading the Swagger now, we get both the versions running, with Information created for both the versions.

Conclusion:

Swagger API documentation works well with the AspNetCore APIs, but when we create versions of APIs with similar routes/methods things get a little rusty. With help from the ApiExplorer nuget and a little tweak on how the Info is generated, we get this setup running without any issues.

We can also extend this setup a bit by ensuring proper default values are loaded for the API documentation with the help of IOperationFilter implementations, but for a quickstart setup this would suffice.

The complete project used in this example is available in here: https://github.com/referbruv/AspNetCore-ApiVersioning-SwaggerUi

Also Read: Versioning APIs in ASP.NET Core Explained - Strategies and Implementations

swagger aspnetcore dotnetcore swaggerui swaggergen apiexplorer documentation exceptions

Join the Newsletter

Subscribe to get our latest content by email.
    We won't send you spam. Unsubscribe at any time.
    We use cookies to provide you with a great user experience, analyze traffic and serve targeted promotions.   Learn More   Accept