Versionando nuestras APIs en dotNet

En este post quiero hablar sobre la importancia de versionar nuestras APIs y cómo hacerlo en nuestras aplicaciones dotNet (hay muchas maneras de hacerlo, aquí solo os voy a contar la que yo utilizo)
¿Por qué versionar nuestras APIs?
Es importante mantener versiones de nuestras APIs, principalmente, para evitar problemas que rompan aplicaciones clientes que la consumen. Estos problemas suelen estar relacionados con cambios en algún DTO (modificando o eliminando alguna propiedad) y con cambios en algún endpoints, modificando su nombre, parámetros que devuelve o retorno.
Teniendo nuestras APIs versionadas, podemos incluir ese tipo de cambios añadiendo una nueva versión y, así, evitar problemas con clientes que utilicen la versión actual.
Implementando versionado en dotNet
Para realizar esta implementación, me he apoyado en el paquete nuget Asp.Versioning.Mvc.ApiExplorer por lo que el primer paso es añadirlo a nuestro proyecto.
Configurando nuestra aplicación
Una vez instalado, añadimos el versionado a nuestro IServiceCollection, con la configuración que deseemos.
builder.Services
.AddApiVersioning(opt =>
{
opt.DefaultApiVersion = new ApiVersion(1, 0);
opt.AssumeDefaultVersionWhenUnspecified = false;
opt.ReportApiVersions = true;
opt.ApiVersionReader = ApiVersionReader.Combine(
new HeaderApiVersionReader("x-api-version"),
new MediaTypeApiVersionReader("x-api-version"),
new UrlSegmentApiVersionReader());
})
.AddApiExplorer(setup =>
{
setup.GroupNameFormat = "'v'VVVV";
setup.SubstituteApiVersionInUrl = true;
});
A continuación, procedo a explicar cada una de las opciones mostradas en el bloque de código anterior:
-
DefaultApiVersion: Versión que se aplica a todos los endpoints que no tengan especificada una versión específica (más adelante veremos las maneras de indicar versiones).
-
AssumeDefaultVersionWhenUnspecified: indica si se asume la versión por defecto, cuando un cliente no proporciona una versión de la API en una llamada a un endpoint. Personalmente, prefiero establecer este valor a false, para obligar a los clientes a indicar la versión del endpoint que quieren consumir.
-
ReportApiVersions: indica si la respuesta a las distintas peticiones contienes información sobre las distintas versiones soportadas por ese endpoint. En el caso de establecer a true esta propiedad, en las cabeceras de las respuestas se añadirán: api-supported-versions y api-deprecated-versions. La primera de ellas indica las distintas versiones del endpoint consumido. Y la segunda informa sobre las versiones que ya están deprecadas.
-
ApiVersionReader: En esta propiedad se informa sobre las distintas formas que tendrá nuestra API para recibir la versión. Existen 3 formas, que vamos a detallar a continuación. Estas son HeaderApiVersionReader, MediaTypeApiVersionReader y UrlSegmentApiVersionReader.
-
GroupNameFormat: Formato utilizado para especificar la versión. A mi me gusta utilizar VVVV, para que siempre se muestre la información de las propiedades major, version minor version y status. En la wiki del proyecto podemos encontrar todos los formatos admitidos. Recomiendo echarle un ojo para elegir el que más se adapte a nuestras necesidades.
-
SubstituteApiVersionInUrl: Indica si el parámetro de versión debe ser sustituido en la url de cada endpoint.
Configurando nuestros controladores
Antes de configurar nuestros controladores, tenemos que saber la forma/formas en las que el cliente puede indicar la versión del endpoint que va a consumir, que hemos especificado en ApiVersionReader.
- HeaderApiVersionReader: La versión se especifica en la cabecera, con la key que hayamos indicado. Para nuestro ejemplo, x-api-version.

- MediaTypeApiVersionReader: Similar al anterior, pero a través de la cabecera de tipo de medio. El nombre de la propiedad también lo definimos nosotros. Y en el ejemplo, en este caso también es x-api-version.

- UrlSegmentApiVersionReader: En este caso, la versión se especificará en la URL. Será en los distintos controladores en los que se indique la posición de la versión en esa URL. Para el ejemplo, vamos a ver que el formato será api/[version]/[controlador]/…

Una vez explicadas las distintas formas en las que podemos recibir la versión, es el momento de preparar nuestros controladores.
Para explicarlo, he creado un ejemplo, con un controlador y un método get.
[ApiController]
public class TestController : ControllerBase
{
[HttpGet]
public IActionResult Test(CancellationToken cancellationToken = default)
{
return Ok("Test");
}
}
Para que este controlador pueda recibir la versión en la url, hay que añadir el siguiente decorador.
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class TestController : ControllerBase
Si, por otro lado, queremos recibir la versión en una cabecera, la ruta de nuestro controlador no tendría porqué cambiar.
[ApiController]
[Route("api/[controller]")]
public class TestController : ControllerBase
Se debe tener en cuenta que todas las formas de recibir la versión pueden convivir, por lo que no es necesario elegir una de ellas.
El siguiente paso, es especificar la versión soportada por nuestro controlador.
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Route("api/[controller]")]
[ApiVersion("1.0")]
public class TestController : ControllerBase
{
[HttpGet]
public IActionResult Test(CancellationToken cancellationToken = default)
{
return Ok("v1.0");
}
}
Llegado a este punto, ya tenemos el controlador que admite peticiones a la versión 1.0. Ahora vamos a ver cómo podemos añadir una nueva versión y dejar deprecada la anterior.
Varias versiones en una misma clase
Podemos decidir tener varias versiones en una misma clase. Éstas se especificarán en el controlador y, para los métodos de cada versión, hay que hacer un mapeo (también mediante un sencillo decorador).
Del mismo modo, si queremos deprecar una versión, solo en necesario especificarlo en el decorador con el que se informa el numero de versión.
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Route("api/[controller]")]
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("1.1")]
public class TestController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public IActionResult Test_V1_0(CancellationToken cancellationToken = default)
{
return Ok("Test v1.0");
}
[HttpGet]
[MapToApiVersion("1.1")]
public IActionResult Test_V1_1(CancellationToken cancellationToken = default)
{
return Ok("Test v1.1");
}
}
Esta forma de ir aumentando las versiones de nuestros endpoints, no es la que más me gusta (prefiero separarlas en clases diferentes) ya que, cuando nuestro proyecto crece mucho y el numero de versiones aumenta, puede ser tedioso el mantenimiento de todas esas versiones en una misma clase.
Una clase por cada versión
Siguiendo con el ejemplo anterior, en el que solo tenemos un endpoint con dos versiones, el resultado sería el suguiente.
namespace TasksWebApi.Controllers.V1_0;
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Route("api/[controller]")]
[ApiVersion("1.0", Deprecated = true)]
public class TestController : ControllerBase
{
[HttpGet]
public IActionResult Test(CancellationToken cancellationToken = default)
{
return Ok("v1.0");
}
}
namespace TasksWebApi.Controllers.V1_1;
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Route("api/[controller]")]
[ApiVersion("1.1")]
public class TestController : ControllerBase
{
[HttpGet]
public IActionResult Test(CancellationToken cancellationToken = default)
{
return Ok("v1.1");
}
}
Vemos que ahora tenemos dos clase TestController, la primera de ellas acepta las peticiones a la version 1.0 y la segunda a la versión 1.1. Cabe destacar que me gusta mantener el nombre de los controladores y sus métodos, separando la versión con el namespace, añadiendo dicho numero de versión al final de éste.
Añadiendo el versionado a swagger
Son muchas las APIs que utilizan swagger a modo de documentación, para mostrar a los consumidores información relativa a ésta (endpoints, requests, responses, versiones…). Por lo que considero interesante explicar cómo he configurado swagger para que muestre las versiones de mi API.
Lo primero que tenemos que hacer es añadir el nuget Swashbuckle.AspNetCore relativo al propio swagger.
Una vez instalado, tenemos que tirar unas cuantas líneas de código.
En lugar de hacerlo todo en Program.cs, prefiero hacer un método de extensión que se encarga de la configuración de swagger.
public static class IServiceCollectionExtensions
{
public static void ConfigureSwaggerOptions(this IServiceCollection services)
{
services.AddSwaggerGen(c =>
{
c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme
{
Description = "ApiKey must appear in header",
Type = SecuritySchemeType.ApiKey,
Name = "x-api-key",
In = ParameterLocation.Header,
Scheme = "ApiKeyScheme"
});
var key = new OpenApiSecurityScheme()
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "ApiKey"
},
In = ParameterLocation.Header
};
var requirement = new OpenApiSecurityRequirement
{
{ key, new List<string>() }
};
c.AddSecurityRequirement(requirement);
});
services.ConfigureOptions<ConfigureSwaggerOptions>();
}
}
En la última línea, se configuran las opciones utilizando la clase que se muestra a continuación, en la que se especifican todas las versiones que acepta nuestra API, extrayéndolas a partir del nuget que añadimos al inicio.
public class ConfigureSwaggerOptions : IConfigureNamedOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider _provider;
public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
{
_provider = provider;
}
public void Configure(SwaggerGenOptions options)
{
foreach (var description in _provider.ApiVersionDescriptions.Distinct())
options.SwaggerDoc(description.GroupName, CreateVersionInfo(description));
}
public void Configure(string name, SwaggerGenOptions options)
{
Configure(options);
}
private OpenApiInfo CreateVersionInfo(ApiVersionDescription desc)
{
var info = new OpenApiInfo()
{
Title = "Test Web API",
Version = desc.ApiVersion.ToString()
};
if (desc.IsDeprecated)
{
info.Description += " This API version has been deprecated. Please use one of the new APIs available from the explorer.";
}
return info;
}
}
Por último, es necesario llamar a dicho método de extensión desde nuestro Program.cs e indicarle que use Swagger y SwaggerUI para mostrar la información de nuestra API (esto lo hago solo si estamos en desarrollo).
builder.Services.AddEndpointsApiExplorer();
builder.Services.ConfigureSwaggerOptions();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
var apiVersionDescriptionProvider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
app.UseSwagger();
app.UseSwaggerUI(options =>
{
foreach (var description in apiVersionDescriptionProvider.ApiVersionDescriptions.Reverse())
{
options.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json",
description.GroupName.ToUpperInvariant());
}
});
}
Con todo lo anterior, si ejecutamos nuestro proyecto y accedemos a …/swagger/index.html, veremos cómo swagger muestra los distintos endpoints agrupándolos por versión.


Por último, hay que saber que el nuget de versionado que se ha utilizado, es valido en versiones de dotNet 6 y 7. Sin embargo, si utilizamos alguna versión anterior, deberíamos utilizar el nuget Microsoft.AspNetCore.Mvc.Versioning. Además, en el momento de añadir las versiones a swagger tendríamos que añadir Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer para poder obtener las versiones que están definidas en nuestra API.
Foto de VICTOR CHARLIE en Unsplash