Esto no es papel mojado!!!

Otro sitio más de Geeks.ms

Versionando WebApi – Después del uno, el dos

El principal problema en la implementación de un API es el versionado de este para que aplicaciones antiguas o que no tenemos nosotros el control no dejen de funcionar.

El pensar que una aplicación no va a evolucionar a lo largo del tiempo es una quimera, y dentro de poco o mucho al final acaba por evolucionar (si es que no muere antes) y por tanto lo mejor es pensar ya en cómo vas a evolucionar y versionar el API de tus aplicaciones para no pensar algo que luego no te va a valer.

El tener una buena estrategia de versionado hace mucho más fácil la evolución de la aplicación, ya que permite mejorar en fases una aplicación. Por ejemplo, se pueden evolucionar los servicios o capa de lógica de negocio y después la capa de presentación u otras aplicaciones dependientes. De esta forma será posible acceder por los dos caminos, el antiguo y el nuevo de forma simultánea mientras lo veamos necesario.

En nuestro caso vamos a analizar cómo versionar WebApi, la capa REST que nos proporciona ASP.Net MVC.

Entorno sin versionado

Para crear el entorno de ejemplo vamos a crear una aplicación de tipo ASP.NET MVC 4 con la plantilla WebApi, esto nos creará toda la estructura de la aplicación necesaria para ejecutar WebApi.

Una vez el proyecto creado añadiremos estas dos clases en la carpeta Modelo, una será la entidad que dará soporte al modelo y la otra la interface del repositorio que nos permitirá acceder a los datos:

public class personas
{
    public int Id { get; set; }
    public string Name { get; set; }
}
public interface IPersonasRepository
{
    IEnumerable<personas> All { get; }
    void Commit();
}

Una vez definido el modelo y la interface del repositorio, veremos cómo será el Controller inicial para responder a WebAPI.

namespace PapelMojado.VersionadoWebApi.Controllers
{
    public class PersonasController : ApiController
    {
        private static PersonasRepository Repository = new PersonasRepository();

        public IEnumerable<personas> Get()
        {
            return Repository.All;
        }
    }
}

La forma en que he implementado el repositorio no es importante porque es para un ejemplo, pero yo os recomendaría para esto ver cómo funciona Entity Framework, NHibernate o cualquier otro repositorio que persista la información.

El el ejemplo he implementado el repositorio como colecciones en memoria y por ello he creado una variable global en el Controller para mantenerlo vivo. De todas formas para implementaciones recomendaría inicializarlo mediante DI + IoC como Unity, Ninject o cualquier otro

Una vez esto implementado vamos a ejecutar la aplicación y realizaremos una pruebas. Al pulsar F5 vemos que en mi caso la aplicación ha arrancado en el puerto 63619 de localhost.

image

Ahora pasamos a arrancar nuestro querido Fiddler (viva el #orgullobackend) y en la pestaña Composer lanzamos la siguiente llamada “GET http://localhost:63619/api/personas

imageimage

Aquí podemos ver como ha devuelto una instancia con id=1 y nombre=Xavi, y por tanto nos aseguramos que el sistema funciona y que tanto la consulta puede visualizarse perfectamente.

Entorno versionado

Una vez que el sistema está en funcionamiento, lo más normal es que nos toque algún día evolucionar el API y cambiar la interface haciendo back-compatibility o lo que es lo mismo que no dejen de funcionar todas las aplicaciones que acceden a esta interface.

En nuestro ejemplo vamos a añadir una nueva propiedad a la entidad persona donde guardaremos el twitter, por ello, la consulta nos devolverá el id, name y twitter. Para hacerlo un poco más interesante, vamos a saltar de la V1 a la V3, dejando un hueco en V2 (simulando que la V2 no afecta a este Controller)

Para resolver esto en todos los casos hay que crear un nuevo controlador que maneje la nueva versión y otra que maneje la anterior, pero existen realmente 2 posibilidades en cuanto a la forma de realizar la llamada:

  1. Tener dos URLs, una con la versión anterior y otra con la nueva para saber diferenciar a qué versión se está llamando. Por ejemplo, se podría utilizar http://localhost:63619/api/personasV1 o http://localhost:63619/api/V1/personas para la versión anterior y http://localhost:63619/api/personasV3 o http://localhost:63619/api/V3/personas para la nueva. En el primer caso se resolvería simplemente creando un nuevo controlador para PersonasV2Controller y en el segundo además habría que modificar el MapHttpRoute del WebApiConfig.cs.
  2. Mantener la misma URL pero añadiendo el header X-Api-Version que indicaría qué versión estamos utilizando. Con esto nos acercaríamos más al estándar y haríamos más fácil la invocación.

Para el ejemplo vamos a tomar esta segunda opción y por ello tendremos los siguientes requisitos:

  • Si viene con el valor 1 deberá ejecutar igual que el ejemplo anterior, el request y el response deberán ser exactamente iguales (a excepción de que ahora aparecerá el header)
  • Si viene con el valor 2 deberá ejecutarse como si de la 1 se tratase, ya que es la versión anterior y por tanto vigente en ese momento
  • Si viene con el valor 3 deberá manejar el nuevo campo twitter
  • Si viene con el valor 4 deberá ejecutarse como si de la 3 se tratase
  • Si no aparece el header de versión deberá devolver la última versión, en nuestro caso también la 3

Para empezar vamos a evolucionar la entidad persona para guardar este nuevo campo, y dejaremos igual el repositorio ya que no hace referencia en ningún momento al nuevo campo:

public class personas
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Twitter { get; set; }
}

Por otro lado tendremos que modificar el antiguo controlador para que continúe devolviendo lo mismo. Para ello realizaremos los siguientes cambios:

  • Crearemos un instancia del siguiente controlador al que llamaremos para obtener la información
  • En el Get devolveremos un enumerador de tipo Object, ya que persona ahora tiene más campos y queremos devolver lo mismo que antes. La idea es instanciar objetos de tipo anónimo compatible con lo anterior a partir del resultado del nuevo controller
namespace PapelMojado.VersionadoWebApi.Controllers.Api.V1
{
    public class PersonasController : ApiController
    {
        private V3.PersonasController controller = new V3.PersonasController();

        public IEnumerable<object> Get()
        {
            return
                from i in controller.Get()
                select new
                {
                    Id = i.Id,
                    Name = i.Name
                };
        }
    }
}

En el caso del controlar de la V3, será por casualidad muy parecido al original con la salvedad del namespace, ya que simplemente retorna las nuevas entidades.

namespace PapelMojado.VersionadoWebApi.Controllers.Api.V3
{
    public class PersonasController : ApiController
    {
        private static PersonasRepository Repository = new PersonasRepository();

        public IEnumerable<personas> Get()
        {
            return Repository.All;
        }
    }
}

Una vez hecho todo esto, si ejecutáis veréis que no funciona y nos informa de que hay múltiples controladores cuyo nombre coincide con el nombre del controlador pasado en la URL.

GET http://localhost:63619/api/personas

imageimage

Para que el sistema de creación del controlador sepa cual utilizar es necesario cambiar el selector de controladores por uno que tenga en cuenta la versión. Para ello se ha creado la clase HeaderVersionControllerSelector y algunas clases auxiliares

    internal class NamespaceLocator : Dictionary<string, ControllerLocator>
    {
        public NamespaceLocator() : base(StringComparer.CurrentCultureIgnoreCase) { }

        public bool TryGetValue(string nameSpace, string controller, int version, out HttpControllerDescriptor controllerDescriptor)
        {
            controllerDescriptor = null;

            ControllerLocator locator;
            if (!TryGetValue(nameSpace, out locator))
                return false;

            return locator.TryGetValue(controller, version, out controllerDescriptor);
        }
        public void Add(string nameSpace, string controller, int version, HttpControllerDescriptor descriptor)
        {
            var locator = this.ElementOrDefault(nameSpace);
            if (locator == null)
            {
                locator = new ControllerLocator(this);
                Add(nameSpace, locator);
            }

            locator.Add(controller, version, descriptor);
        }
    }
    internal class ControllerLocator : Dictionary<string, VersionLocator>
    {
        public NamespaceLocator Parent { get; private set; }

        public ControllerLocator(NamespaceLocator parent)
            : base(StringComparer.CurrentCultureIgnoreCase)
        {
            Parent = parent;
        }
        public bool TryGetValue(string controller, int version, out HttpControllerDescriptor controllerDescriptor)
        {
            controllerDescriptor = null;

            VersionLocator locator;
            if (!TryGetValue(controller, out locator))
                return false;

            return locator.TryGetValue(version, out controllerDescriptor);
        }
        public void Add(string controller, int version, HttpControllerDescriptor descriptor)
        {
            var locator = this.ElementOrDefault(controller);
            if (locator == null)
            {
                locator = new VersionLocator(this);
                Add(controller, locator);
            }

            locator.Add(version, descriptor);
        }
    }
    internal class VersionLocator : Dictionary<int, HttpControllerDescriptor>
    {
        public ControllerLocator Parent { get; private set; }

        public VersionLocator(ControllerLocator parent)
        {
            Parent = parent;
        }
        public new bool TryGetValue(int version, out HttpControllerDescriptor controllerDescriptor)
        {
            controllerDescriptor =
                (
                    from v1 in this
                    where v1.Key ==
                                (
                                    from v2 in this.Keys
                                    where v2 <= version
                                    select v2
                                ).Max()
                    select v1.Value
                ).FirstOrDefault();

            return controllerDescriptor != null;
        }
    }
    public class HeaderVersionControllerSelector : IHttpControllerSelector
    {
        private readonly HttpConfiguration _config;
        private readonly IHttpControllerSelector _previousSelector;
        private readonly NamespaceLocator _namespaceLocator = new NamespaceLocator();
        private const string ApiNamespace = "PapelMojado.VersionadoWebApi.Controllers";
        private const int MaxVersion = 9999;

        public HeaderVersionControllerSelector(IHttpControllerSelector previousSelector, HttpConfiguration config)
        {
            _config = config;
            _previousSelector = previousSelector;

            var types =
                from t in Assembly.GetExecutingAssembly().GetTypes()
                where
                    typeof(ApiController).IsAssignableFrom(t) &&
                    t.Namespace != null && t.Namespace.StartsWith(ApiNamespace, StringComparison.CurrentCultureIgnoreCase)
                select new
                {
                    SubNamespace = t.Namespace.Substring(ApiNamespace.Length),
                    Type = t
                };

            foreach (var type in types)
            {
                var subNamespaces = type.SubNamespace.Split('.') as IEnumerable<string>;
                if (subNamespaces.ElementAt(0).IsNullOrEmpty())
                    subNamespaces = subNamespaces.Skip(1);

                var lastNamespace = subNamespaces.LastOrDefault();
                if (string.Compare(lastNamespace.SubstringWithoutError(0, 1), "v", true) == 0)
                    lastNamespace = lastNamespace.Substring(1);

                int version;
                var initialNamespace = lastNamespace;
                if (!int.TryParse(lastNamespace, out version))
                    version = MaxVersion;
                else
                    initialNamespace = subNamespaces.Reverse().Skip(1).Reverse().JoinString(".");

                _namespaceLocator.Add(initialNamespace, type.Type.Name, version, new HttpControllerDescriptor(_config, type.Type.Name, type.Type));
            }
        }
        public IDictionary<string, HttpControllerDescriptor> GetControllerMapping()
        {
            var result = (
                                from n in _namespaceLocator
                                from c in n.Value
                                from v in c.Value
                                select new
                                {
                                    Key =
                                        n.Key + "." +
                                        (v.Key < MaxVersion ? "V" + v.Key + "." : "") +
                                        c.Key,
                                    Value = v.Value
                                }
                            ).ToDictionary(x => x.Key, x => x.Value);

            return result;
        }
        public HttpControllerDescriptor SelectController(HttpRequestMessage request)
        {
            var routeData = _config.Routes.GetRouteDataExtended(request);

            var nameSpace = GetPath(request).Replace('/', '.');
            var controllerName = routeData["controller"] as string + "controller";
            var version = routeData.ContainsKey("X-Api-Version") ? Convert.ToInt32(routeData["X-Api-Version"]) : MaxVersion;

            HttpControllerDescriptor controllerDescriptor;
            if (_namespaceLocator.TryGetValue(nameSpace, controllerName, version, out controllerDescriptor))
                return controllerDescriptor;

            return null;
        }
        private string GetPath(HttpRequestMessage request)
        {
            return string.Join("/",
                from r in request.GetRouteData().Route.RouteTemplate.Split('/')
                where !r.StartsWith("{")
                select r
            );
        }
    }

Aquí se pueden ver las 3 clases auxiliares (NamespaceLocator, ControllerLocator y VersionLocator) que forman la estructura que guardarán de forma jerárquica (Namespace/Controller/Version) el HttpControllerDescription del controlador ya versionado.

Para hacer el mapping entre la URL y el Controlador

La URL http://localhost:63619/api/personas se desglosará en:

  • Namespace: api (penúltimo directorio de la URL)
  • Controlador: personas (ultimo directorio de la URL) + Controller (sufijo que siempre se pone)
  • Versión: V (prefijo de la versión) + lo que ponga en el X-Api-Version

Al mismo tiempo que la clase namespace PapelMojado.VersionadoWebApi.Controllers.Api.V1 { public class PersonasController : ApiController … se desglosará en:

  • Namespace: Api (penúltimo subespacio de nombres)
  • Controlador: Personas (nombre de la clase)
  • Versión: V1 (último subespacio de nombres)

Con todo esto, en el constructor de HeaderVersionControllerSelector se crea la estructura analizando por reflexión las clases del assembly, y en el método SelectController se parsea la url de la petición y se busca el controlador correspondiente recorriendo el árbol anteriormente creado.

Aquí se tiene en cuenta el caso de que no exista una versión de un controlador, con lo que se devolvería el de la versión anterior más próximo. Si no llegase la versión en la URL se devolvería el último (máxima versión del árbol).

Para simplificar un poco estas clase, se han creado algunos extensores que simplifiquen el código y que lo hagan un poco más legible:

    public static class DictionaryExtension
    {
        public static V ElementOrDefault<K, V>(this Dictionary<K, V> THIS, K key)
        {
            if (THIS.ContainsKey(key))
                return THIS[key];

            return default(V);
        }
    }
    public static class HttpRequestMessageExtension
    {
        public static IDictionary<string, object> GetRouteDataExtended(this IHttpRoute THIS, string virtualPathRoot, HttpRequestMessage request)
        {
            var routeData = THIS.GetRouteData(virtualPathRoot, request);
            if (routeData == null)
                return new Dictionary<string, object>();
            var result = routeData.Values;

            foreach (var h in request.Headers)
                result.Add(h.Key, h.Value.FirstOrDefault());

            return result;
        }
    }
    public static class HttpRouteCollectionExtension
    {
        public static IDictionary<string, object> GetRouteDataExtended(this HttpRouteCollection THIS, HttpRequestMessage request)
        {
            var routes = (
                from r in THIS
                where r is IHttpRoute
                select r
            );

            var result = new Dictionary<string, object>();
            foreach (var route in routes)
                foreach (var routeData in route.GetRouteDataExtended("", request))
                    result.Add(routeData.Key, routeData.Value);
            return result;
        }
    }
    public static class IEnumerableExtension
    {
        public static string JoinString(this IEnumerable<string> THIS, string separator)
        {
            return string.Join(separator, THIS);
        }
    }
    public static class StringExtension
    {
        public static bool IsNullOrEmpty(this string THIS)
        {
            return string.IsNullOrEmpty(THIS);
        }
        public static string SubstringWithoutError(this string THIS, int startIndex, int length)
        {
            startIndex = Math.Min(startIndex, THIS.Length);
            length = Math.Min(length, THIS.Length - startIndex);

            return THIS.Substring(startIndex, length);
        }
    }

Y para finalizar debemos añadir 2 líneas al método Application_Start del Application para sustituir el selector por defecto por este que tiene en cuenta las versiones.

    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();

            WebApiConfig.Register(GlobalConfiguration.Configuration);
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);

            // Version manager
            var previousSelector = GlobalConfiguration.Configuration.Services.GetService(typeof(IHttpControllerSelector)) as IHttpControllerSelector;
            GlobalConfiguration.Configuration.Services.Replace(typeof(IHttpControllerSelector), new HeaderVersionControllerSelector(previousSelector, GlobalConfiguration.Configuration));
        }
    }

Pruebas finales

 

Para comprobar todo esto ejecutaremos

Versión 1 (versión anterior)

imageimage

Versión 2 (versión anterior)

Al no encontrarse al V2 se devuelve V1

imageimage

Versión 3 (versión nueva)

imageimage

Versión 4 (versión nueva)

Al no encontrarse al V4 se devuelve V3

imageimage

Versión por defecto (versión nueva)

Al no enviarse versión se envía la última que es la V3

imageimage

En el caso de otros métodos de tipo post/put/delete/… es exactamente igual, ya que lo importante aquí es que en tiempo de ejecución y en función del header X-Api-Version se decide que Controller es el que ejecuta la petición.

Para ver los ficheros modificados y la estructura de directorios que se ha quedado

image

Conclusiones

Con esta estrategia de versionado hemos conseguido:

  • Podemos invocar a cualquier versión del Controller que hayamos implementado
  • No es necesario modificar las URLs de los servicios WebApi según la versión, solo marcando el header correspondiente se indica qué versión queremos invocar.
  • Sólo tenemos que versionar los Controllers que necesitemos, no todos. Esto tiene como ventaja que si la V2 sólo afecta a un Controller el resto se pueden quedar iguales, ya que cuando se invoque con la V2 si no existe se devolverá justo la que estaba en ese momento en vigor.
  • Y como consecuencia se puede evolucionar una aplicación mucho más fácilmente ya que puedes modificar los servicios sin que ningunos de los componentes dependientes se vea afectado.
  • Al implementar el antiguo controlador en función del nuevo y no echando mano del repositorio, nos aseguramos que en posteriores versiones continuará funcionando, ya que los resultados se irán migrando en cascada y no dependerán del repositorio, que obligaría a modificar todos los Controllers cada vez que este varíe.

Espero que sea de utilidad

Au

3 Comentarios

  1. unai

    No está del todo mal Xavi… buen trabajo. Este es un tema sobre el que le dimos feedback en el MVP Summit y sino me equivoco están trabajando ya en ello para aportar guidelines y algún base code para hacer este trabajo… mientras tanto es muy interesante que la gente vea distintas opciones como esta que presentas….

    Saludos
    Unai

  2. julitogtu

    Excelente post amigo, muchas veces nos centramos es contruir y no se dimensionan este tipo de casos que siempre llegan, desde el lado de los servicios lo veo comodo, lo que no veo tan claro es la parte de los repositorios, ya que en mi opinión no me gusta dejar lógica en en controlador con en el caso de retornar el ienumerable.

  3. xavipaper

    Muchas gracias Unai y Julio por vuestros comentarios, para mi los comentarios siempre son positivos ya que me hacen reflexionar y mejorar (por ejemplo este algoritmo de versionado lo tengo en aplicaciones en producción)

    Unai, me alegro de que Microsoft mejore en esta línea la interface WebAPI, ya que es un problema que todos nos encontramos al evolucionar una aplicación. Microsoft está evolucionando mucho y en la mayoria de casos con una buena orientación, así estaremos superatentos a las mejoras que vayan apareciendo para estudiarlas y comentarlas 😉

    En cuanto a lo que Julio comenta sobre el repositorio, yo en el caso de una aplicación real me iria a una arquitectura DDD, por lo que realmente el Controllador se comunicaría directamente con la capa de Application donde se resolvería la lógica de negocio. Pero esto no lo ví importante al escribir el post ya que el objetivo no era el enganche con la lógica, sino el cómo se resuelve la problematica de versionado.

    Gracias por los comentarios

Deja un comentario

Tema creado por Anders Norén