Hace unos meses tuvimos que crear una Web API y, por supuesto, uno de los requisitos principales era versionarla (también tenía que funcionar, pero eso no prometía ser tan divertido). Aunque inicialmente estuvimos sopesando la idea de usar algún paquete Nuget (como por ejemplo https://github.com/Sebazzz/SDammann.WebApi.Versioning) al final y en un alarde de “vamos a hacerlo a mano que así aprendemos más y no adquirimos una dependencia” nos tiramos al barro e implementamos el versionado 100% home-made. Si ha sido o no una decisión correcta lo sabremos con el tiempo…. Para más inri, han aparecido nuevos paquetes Nuget que prometen mucho http://www.hanselman.com/blog/ASPNETCoreRESTfulWebAPIVersioningMadeEasy.aspx pero ya es demasiado tarde (al menos por ahora).
Por otro lado y como hace poco me descubrí a mí mismo preguntándome qué diablos hacía este código, es ese el motivo de escribir este post, dejar por escrito que motivaciones nos empujaron en su día a tomar un montón de decisiones que parecían oportunas y llenas de razón… y aquí aprovecho para meter el disclaimer “lo hemos hecho lo mejor que hemos podido”
Viendo algunos de los distintos tipos de versionado más populares, irá discurriendo el post.
URI Path
Versionar por ruta. El más común, primigenio e intuitivo.
Aunque los ejemplos inmediatos no pueden considerarse una buena práctica (de hecho, nadie lo haría así), partiendo de la ruta “/api/Customers” lo más sencillo si queremos versionar sería crear un nuevo controlador y añadirle al nombre un sufijo de número de versión.
public class Customers2Controller : ApiController { // GET api/Customers2 public IEnumerable<Customer2> Get() { } }
Ahora, además de “/api/Customers” también tendríamos “/api/Customers2”.
Una segunda opción más recomendable sería utilizar el atributo Route.
[Route("api/Customers2")] public IEnumerable<Customer2> Get() { }
Otras rutas válidas (y seguro más convenientes que las vistas hasta ahora) serían:
- api/v2/Customers
- apiv2.example.com/Customers
En la primera el número de versión está en un segmento de la ruta lo más a la izquierda posible.
En la segunda es el nombre de host quien incluye el número de versión. De hecho, “dicen” que algunos incluso crean un alias para que api.example.com apunte a api<última_versión>.example.com.
En ambos casos, lo mejor para no complicarse la vida con el número de versión es que sea un simple número y no un major.minor.patch. Además, si hacemos obligatorio el uso de versión, esto es que no funcione ni api/Customers ni api.example.com, garantizamos que no habrá ninguna sorpresa con los clientes ni dejarán de funcionar cuando subamos de versión.
El versionado por URI Path nos permite cambiar drásticamente nuestra API porque todo lo que sigue a v<versión> podría cambiar de una versión a otra. Es decir, podríamos pasar de api/v1/Customers, ya no a api/v2/Customers sino a api/v2/Clientes. Lógicamente, esta libertad de cambios supone que los clientes tendrían que actualizar su código para apuntar a las nuevas rutas, y no sólo para cambiar el segmento de la versión sino también para cambiar el resto de la URL.
Además, cualquier bookmark, permalink o similar dejará de funcionar y, si no queremos romper nada, tendremos que configurar nuestra aplicación para devolver un 302 Found o un 301 Moved permanently.
Por otro lado, desde el punto de vista teórico (y poniéndonos la gorra de restafari), tenemos 2 endpoints que, en vez de devolver 2 recursos, devuelven el mismo con una representación distinta. Alguien te dirá que eso no es correcto (no seré yo).
Una solución para intentar atajar globalmente este tipo de versionado en nuestra aplicación sería indicar a la ruta que sólo buscará controladores en un espacio de nombres concreto, pero Web API no permite especificar constraint namespaces en una ruta tal y cómo sí lo hace MVC, luego tendremos que crear una implementación propia de IHttpControllerSelector que entienda rutas con la plantilla api/{namespace}/{controller}/{id} y busque controladores sólo en el espacio de nombres especificado en {namespace}.
La implementación está aquí https://blogs.msdn.microsoft.com/webdev/2013/03/07/asp-net-web-api-using-namespaces-to-version-web-apis/ y bastaría con añadir una ruta por convención y crear los controladores en un espacio de nombres por versión. De este modo tanto api/v1/Customers como api/v2/Customers funcionarían correctamente y no habría ambigüedad en la búsqueda del controlador.
var config = new HttpConfiguration(); config.MapHttpAttributeRoutes(); config.Routes.MapHttpRoute( name: "DefaultApi", routeTemplate: "api/{namespace}/{controller}/{id}", defaults: new { id = RouteParameter.Optional } ); config.Services.Replace(typeof(IHttpControllerSelector), new NamespaceHttpControllerSelector(config));
Cada controlador en su espacio de nombres por versión:
namespace ConsoleApplication1.Controllers.v1 { public class CustomersController : ApiController } namespace ConsoleApplication1.Controllers.v2 { public class CustomersController : ApiController }
Ya lo dice el post de donde se tomó el ejemplo de IHttpControllerSelector, y es que quizás habría que hacer un fallback a un número de versión anterior si no se encuentra ningún controlador que coincida con el namespace buscado, porque si no cada vez que subiéramos versión tendríamos que copiar todos los controladores de la anterior versión a la nueva, aunque sólo haya cambiado uno. Es decir, si sólo ha cambiado api/v2/Customers quiero que api/v2/Orders siga ejecutando api/v1/Orders y no tener que copiar OrdersControllers al espacio de nombres v2 aunque no haya sufrido ningún cambio. Al final del post veremos como lo hemos resuelto en nuestro caso.
URI Parameter
En este método la versión se especifica como un parámetro de la querystring. Por ejemplo: api/Customers?version=2
Tanto URI Path como URI Parameter podrían ser la única opción disponible si queremos dar soporte a clientes que no pueden manipular las cabeceras de la petición.
Para implementar este tipo de versionado en ASP.NET Web API, tomaremos prestada la idea desde https://www.asp.net/web-api/overview/releases/whats-new-in-aspnet-web-api-21 donde hay un link a un ejemplo para versionar a través de attribute routing http://aspnet.codeplex.com/SourceControl/latest#Samples/WebApi/RoutingConstraintsSample/ReadMe.txt
Tomando el ejemplo como base, lo haremos nuestro e iremos agregando código a medida que vayamos viendo el resto de opciones de versionado (al final todo morirá en filtrar una ruta en función de la presencia de un valor en la petición). Por ahora, sólo queremos controlar api/Customers?version=1 y api/Customers?version=2.
Los métodos de acción quedarían así:
public class CustomersController : ApiController { [VersionedRoute("api/Customers", 1)] public IEnumerable<Customer> GetCustomers1() { return new List<Customer>() { }; } [VersionedRoute("api/Customers", 2)] public IEnumerable<Customer2> GetCustomers2() { } }
Código de VersionedRoute (siempre será el mismo con independencia del tipo de versionado):
class VersionedRoute : RouteFactoryAttribute { private readonly int _allowedVersion; private const int DefaultVersion = 1; public VersionedRoute(string template) : this(template, DefaultVersion) { } public VersionedRoute(string template, int allowedVersion) : base(template) { _allowedVersion = allowedVersion; } public override IDictionary<string,object> Constraints => new HttpRouteValueDictionary { { "version", new VersionConstraint(_allowedVersion, DefaultVersion) } }; }
Código de VersionConstraint:
class VersionConstraint : IHttpRouteConstraint { private readonly int _allowedVersion; private readonly int _defaultVersion; public VersionConstraint(int allowedVersion, int defaultVersion) { _allowedVersion = allowedVersion; _defaultVersion = defaultVersion; } public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string,object> values, HttpRouteDirection routeDirection) { if (routeDirection != HttpRouteDirection.UriResolution) { return false; } var version = GetVersionFromQueryString(request) ?? _defaultVersion; return version == _allowedVersion; } private static int? GetVersionFromQueryString(HttpRequestMessage request) { int version; if (int.TryParse(GetQueryStringValue(request, "version"), out version)) { return version; } return null; } private static string GetQueryStringValue(HttpRequestMessage request, string key) { var values = request.GetQueryNameValuePairs(); if (values.All(p => !string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase))) { return null; } return values.Single(p => string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase)).Value; } }
Custom Header
Con este método la idea es incluir en la petición una cabecera personalizada del estilo X-Version o similar. El prefijo X- es una convención para cabeceras personalizadas, que no son parte del estándar.
Aunque este método no ensucia la URL (separa la información de versión del área de superficie expuesta por nuestra Web Api), el inconveniente es que ya no podremos copiar y pegar la URL, agregar un favorito o pasar la dirección por correcto electrónico. Ahora el cliente tiene que poder enviar una cabecera personalizada en la petición y nosotros, como desarrolladores, tendremos que utilizar Fiddler o una herramienta similar.
Para su implementación, tendremos que modificar la clase VersionConstraint para soporte adicionalmente el versionado por Custom Header.
public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string,object> values, HttpRouteDirection routeDirection) { if (routeDirection != HttpRouteDirection.UriResolution) { return false; } var version = GetVersionFromQueryString(request); if (version != null) { return version == _allowedVersion; } version = GetVersionFromHeaders(request) ?? _defaultVersion; return version == _allowedVersion; } private static int? GetVersionFromHeaders(HttpRequestMessage request) { IEnumerable<string> headerValues; if (request.Headers.TryGetValues("x-version", out headerValues)) { int version; if (int.TryParse(headerValues.First(), out version)) { return version; } } return null; }
Content Negotiation
Se basa en el uso de la cabecera Accept y un MIME Type personalizado donde se especifica que versión del recurso queremos obtener.
Se presenta en 2 distintas formas:
- application/json; version=1
- application/vnd.<compañía>.<recurso>+json; version=1
En la primera se usa el tipo MIME estándar y se le agrega un parámetro de versión.
En la segunda se usa un tipo MIME personalizado donde se especifica tanto la versión como el formato deseado.
Por cierto, vnd es de vendor https://en.wikipedia.org/wiki/Media_type
Para implementar ambas, ASP.NET nos ayudará porque cualquier cabecera acepta parámetros y el framework los parsea automáticamente:
public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string,object> values, HttpRouteDirection routeDirection) { if (routeDirection != HttpRouteDirection.UriResolution) { return false; } var version = GetVersionFromQueryString(request); if (version != null) { return version == _allowedVersion; } version = GetVersionFromHeaders(request); if (version != null) { return version == _allowedVersion; } version = GetVersionFromAcceptHeader(request) ?? _defaultVersion; return version == _allowedVersion; } private static int? GetVersionFromAcceptHeader(HttpRequestMessage request) { var accept = request.Headers.Accept.SingleOrDefault(a =>; a.Parameters.Any(p => string.Equals(p.Name, "version", StringComparison.OrdinalIgnoreCase))); if (accept != null) { int version; if (int.TryParse(accept.Parameters.Single(p =>; string.Equals(p.Name, "version", StringComparison.OrdinalIgnoreCase)).Value, out version)) { return version; } } return null; }
Cuando se complica la implementación es si queremos usar la segunda opción y queremos seguir aceptando la negociación de contenido. Por ejemplo, y para nuestro tipo Customer, las siguientes peticiones devolverán siempre json (porque es el primer MediaTypeFormatter que está registrado en GlobalConfiguration.Configuration.Formatters).
- Accept: application/vnd.example.com+json; version=2
- Accept: application/vnd.example.com+xml; version=2
Si lo primero que hacemos al empezar un proyecto con Web API es eliminar el XmlFormatter o, lo que es lo mismo, sólo devolver json, lo anterior no es un problema. Ahora bien, si tenemos que soportar negociación de contenido, tendremos que agregar un MediaTypeFormatter para cada tipo y para cada representación. Un ejemplo completo se puede ver en http://robertgaut.com/Blog/2007/Four-Ways-to-Version-Your-MVC-Web-API
Como muestra el post, lo hay que hacer es crear un par de MediaTypeFormatter (uno para json y otro para xml) y registrar nuestros tipos. Yo entiendo que, si se opta finalmente por este tipo de versionado, la reflexión sería una solución digna frente a tener que registrar manualmente todos los tipos de nuestra aplicación. En cualquier caso, para nuestro ejemplo:
public static class TypeExtensions { public static Type GetTypeFromIEnumerable(this Type type) { return IsIEnumerable(type) ? type.GetGenericArguments()[0] : null; } private static bool IsIEnumerable(Type type) { return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>); } } class TypedXmlMediaTypeFormatter : XmlMediaTypeFormatter { private readonly Type _resourceType; public TypedXmlMediaTypeFormatter(Type resourceType, MediaTypeHeaderValue mediaType) { _resourceType = resourceType; SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(mediaType); } public override bool CanReadType(Type type) { return _resourceType == type || _resourceType == type.GetTypeFromIEnumerable(); } public override bool CanWriteType(Type type) { return _resourceType == type || _resourceType == type.GetTypeFromIEnumerable(); } } class TypedJsonMediaTypeFormatter : JsonMediaTypeFormatter { private readonly Type _resourceType; public TypedJsonMediaTypeFormatter(Type resourceType, MediaTypeHeaderValue mediaType) { _resourceType = resourceType; SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(mediaType); } public override bool CanReadType(Type type) { return _resourceType == type || _resourceType == type.GetTypeFromIEnumerable(); } public override bool CanWriteType(Type type) { return _resourceType == type || _resourceType == type.GetTypeFromIEnumerable(); } }
Y el registro:
config.Formatters.Insert(0, new TypedXmlMediaTypeFormatter(typeof(Customer), new MediaTypeHeaderValue("application/vnd.example.com+xml"))); config.Formatters.Insert(0, new TypedJsonMediaTypeFormatter(typeof(Customer), new MediaTypeHeaderValue("application/vnd.example.com+json"))); config.Formatters.Insert(0, new TypedXmlMediaTypeFormatter(typeof(Customer2), new MediaTypeHeaderValue("application/vnd.example.com+xml"))); config.Formatters.Insert(0, new TypedJsonMediaTypeFormatter(typeof(Customer2), new MediaTypeHeaderValue("application/vnd.example.com+json")));
En este punto ya tenemos un atributo VersionedRoute que ha quedado bastante aparente, pero seguimos sin resolver el problema de cómo proceder cuando subamos de versión para los endpoints que no tienen cambios. Es decir, si decoramos una acción del controlador con VersionedRoute(“…”, 2), ésta sólo responderá cuando el cliente especifica la versión 2. Sin embargo, si mi API actual está en la versión 3 y el anterior endpoint no ha cambiado, no quiero tener que cambiar manualmente todos estos endpoints sin cambios a la versión 3, de hecho, no debería, si lo hago voy a romper con todos los clientes que no actualicen su código a la última versión, luego quiero que ese endpoint sin cambios responda tanto a la versión 3 como a la versión 2.
Para solucionarlo, la idea pasa porque cada endpoint sepa reconocer a cuáles versiones puede hacer fallback sin riesgo alguno. Para ello, usaremos la siguiente clase que a grandes rasgos hace:
- Establecer el actual número de versión (el valor más alto, en mi caso el endpoint más digievolucionado).
- Este valor hay que mantenerlo manualmente y además es el número de versión por defecto que se usará cuando en VersionedRoute no lo especifiquemos.
- Buscar por reflexión rutas versionadas y calcular dinámicamente a que versiones de la misma ruta puede hacer fallback.
internal class Versioning { public const int CurrentVersion = 3; public static readonly Lazy<IEnumerable<FallbackRoute>> FallbackRoutes; static Versioning() { FallbackRoutes = new Lazy<IEnumerable<FallbackRoute>>(GetFallbackRoutes); } private static IEnumerable<FallbackRoute> GetFallbackRoutes() { var fallbackRoutes = GetFallbackRoutesFromVersionedRoutes(); foreach (var routeTemplate in fallbackRoutes.Select(p => p.RouteTemplate).Distinct()) { var lastFallbackRouteIndexFound = 0; for (var version = CurrentVersion; version > 0; version--) { if (fallbackRoutes.Any(MatchFallbackRoute(routeTemplate, version))) { lastFallbackRouteIndexFound = version; continue; } fallbackRoutes.Single(MatchFallbackRoute(routeTemplate, lastFallbackRouteIndexFound)) .AddFallbackVersion(version); } } return fallbackRoutes; } private static IEnumerable<FallbackRoute> GetFallbackRoutesFromVersionedRoutes() { return Assembly.GetExecutingAssembly().GetTypes() .SelectMany(t => t.GetMethods()) .Where(m => m.GetCustomAttributes(typeof(VersionedRoute), false).Length > 0) .Select(m => { var route = m.GetCustomAttribute<VersionedRoute>(); return new FallbackRoute(route.Template, route.AllowedVersion) ; }).ToList(); } private static Func<FallbackRoute, bool> MatchFallbackRoute(string routeTemplate, int allowedVersion) { return f => (f.RouteTemplate == routeTemplate) && (f.AllowedVersion == allowedVersion); } public static FallbackRoute GetFallbackRoute(string routeTemplate, int allowedVersion) { return FallbackRoutes.Value.SingleOrDefault(MatchFallbackRoute(routeTemplate, allowedVersion)); } }
FallbackRoute es una clase que simplemente guardar una ruta y a que versiones atiende:
internal class FallbackRoute { public FallbackRoute(string routeTemplate, int allowedVersion) { RouteTemplate = routeTemplate; AllowedVersion = allowedVersion; FallbackVersions = new List(); } public string RouteTemplate { get; } public int AllowedVersion { get; } public IEnumerable FallbackVersions { get; } public bool HasFallbackVersion(int version) { return FallbackVersions.Contains(version); } public void AddFallbackVersion(int version) { ((IList )FallbackVersions).Add(version); } }
Y en VersionedConstraint hay que cambiar el código del método Match para que sepa a qué rutas de fallback responderá:
public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values, HttpRouteDirection routeDirection) { if (routeDirection != HttpRouteDirection.UriResolution) { return false; } var version = GetVersion(request) ?? _defaultVersion; var fallbackRoute = Versioning.GetFallbackRoute(route.RouteTemplate, _allowedVersion); return version == _allowedVersion || (fallbackRoute != null && fallbackRoute.HasFallbackVersion(version)); }
Con estos cambios y asumiendo que CurrentVersion = 3, funcionarán las siguientes rutas:
- VersionedRoute(“api/Customers”, 1) responderá a la versión 1.
- VersionedRoute(“api/Customers”, 3) responderá a la versiones 2 y 3.
- VersionedRoute(“api/Orders”, 2) responderá a la versiones 2 y 1.
- VersionedRoute(“api/Orders”) responderá a la versión 3.
El cómo organizar el código en namespaces atendiendo a distintas representaciones versionadas de una misma entidad es cosa de cada uno, es decir, esta solución no obliga (y tampoco facilita) el buscar controladores en un espacio de nombres concreto, eso queda a libre elección del consumidor.
Si por algún motivo te parece una buena solución, te recomendaría usar el código de github https://github.com/panicoenlaxbox/WebApiVersioning donde, seguro, estará la versión más actualizada y que funciona.
Un saludo!
Muy buena publicación.
ResponderEliminarPor mi parte tengo que escribir sobre mi experiencia en el asunto. Incluso prepare una charla informativa para la empresa, te la dejo aquí por si quieres echarle un vistazo: http://www.slideshare.net/RodrigoEzequielLiber/rest-versioning-architecture-with-aspnet-mvc-web-api-v12-66856880
Particularmente en proyectos pequeños, a nivel de código suelo favorecer que la versión sea parte del nombre de la clase del controlador, pero en proyectos grandes, con evolución prolongada en el tiempo, prefiero la tendencia de emplear versionado por espacio de nombres.
En cuanto a estrategias de versionados, para poder soportar el mayor número de potenciales clientes, suelo ofrecer las tres más comunes: por URI, por custom header y por media content negociation.
Gracias por tu comentario Rodrigo y por supuesto, a la saca tus slides, ¡96 nada menos!
ResponderEliminar