¿Están tus servicios REST en otro servidor?

Muy buenas, en este post vamos a hablar de lo que ocurre si los servicios REST de tu aplicación están en otro servidor distinto al de tu aplicación web… Terminaremos hablando de CORS, pero antes lo haremos de JSONP y empezaremos por el…

… Orígen

No, no me refiero a la onírica película con Di Caprio, aunque muchas veces el desarrollo web se parezca a una pesadilla, si no a lo que en el mundo web entendemos como el origen de una web.

Es un concepto muy sencillo pero que debemos tener siempre presente: protocolo, host y número de puerto conforman un orígen. Así las urls

¿Es simple no? Bueno pues ahora la regla de oro: Los navegadores tienen prohibido procesar una llamada Ajax (con XmlHttpRequest) desde una página que esté en un orígen hacia una URL que sea de otro origen.

Punto.

Vale, que nos impidan hacer peticiones ajax a otro dominio es una muy buena medida de seguridad, pero a veces es una necesidad legítima: Imagina que tienes la API REST de tu aplicación publicada en http://api.foo.com y tu aplicación web en http://foo.com. Pues si desde tu web quieres hacer una petición ajax a alguno de tus servicios: mala suerte.

Para hacer algunas demos voy a crear una solución de vs2012 con dos proyectos web:

image

Uno será mi aplicación web y el otro será mi API. El proyecto WebApi (que yo he llamado CORSDemo) tan solo tiene un controlador, que responde a las peticiones de tipo /Beer/id:

public class BeersController : ApiController

{

    public Beer Get(int id)

    {

        return new Beer {Name = "Cerveza " + id, Id = id};

    }

}

Por otro lado la aplicación web (que he llamado CORSDemo.Web) tiene un solo controlador (Home) que devuelve una vista. Dicha vista intenta hacer una llamada Ajax al servicio REST:

<h2>Index</h2>

 

@section scripts

{

    <script>

    (function() {

            var xhr = new XMLHttpRequest();

            console.log(‘Invocando servicio REST’);

            xhr.open(‘GET’, ‘http://localhost:2614/Beers/10’, true);

            xhr.setRequestHeader("Accept", "application/json");

            xhr.addEventListener(‘readystatechange’, function (e) {

                console.log(‘readyState: ‘ + xhr.readyState)

                console.log(‘status: ‘ + xhr.status);

                console.log(‘response: ‘ + xhr.responseText);

            });

            xhr.send();

    })();

    </script>

}

Si pongo en marcha el proyecto, efectivamente en mi IIS Express tengo dos aplicaciones web:

image

En mi caso la aplicación WebApi está en localhost:2614 y la aplicación web está en localhost:2628. Y si navego a localhost:2628 veo lo que ya me esperaba:

image

El navegador me muestra el error que no puede realizar la llamada ya que el servicio REST está en otro orígen.

Rompiendo la barrera – jsonp

Por suerte (y por desgracia también, todo tiene las dos caras de la moneda), hay muchas cabezas pensantes por ahí y algunas de ellas se dedicaron a ver si existía alguna posible manera de saltarse esta medida de seguridad. Y dieron con una. Ciertamente no abrieron un boquete en la muralla de seguridad, pero sí una brecha y durante vario tiempo nos hemos estado aprovechando de ella. Esta brecha es la técnica conocida como jsonp. Veamos muy brevemente en que consiste…

El objetivo final es conseguir llamar al servicio REST que tenemos y recuperar los datos. Eso debemos hacerlo de forma asíncrona al igual que hace XMLHttpRequest, que ya hemos visto que no podemos usar.

La técnica de jsonp es muy simple, pero requiere eso sí que los servicios REST devuelvan json (no sirve si devuelven algún otro tipo de datos como XML).

Consiste básicamente en sustuir la llamada AJAX por un tag <script>. El tag <script> permite sin ningún problema incluir scripts de otros orígenes (si no, no podríamos usar CDNs p. ej.). Asi en nuestro caso vamos a añadir un tag <script> como el siguiente:

<script src="http://localhost:2614/Beers/10"></script>

Ahora el navegador realiza la llamada web y obtiene el JSON pero… por supuesto ahora tenemos un error de javascript:

image

Eso es debido a que el navegador está intentando interpretar el JSON como si fuese código javascript y por supuesto {"Name":"Cerveza 10","Id":10} no es un código javascript válido. No lo es, pero le falta muy, muy poco para serlo.

Ahora toca que el servicio REST colabore un poco. Que nos devuelva los datos directamente en JSON no nos sirve ya que hemos visto que el navegador no puede interpretarlos. Pero… y si en lugar de devolvernos los datos en JSON el servicio REST nos devuelve algo como:

func_callback({"Name":"Cerveza 10","Id":10});

Ah! Esto sí que es javascript válido. A este código lo llamamos el código jsonp.

Tan solo falta que func_callback esté definida y de eso ya se encargaría la aplicación web.

Veamos como modificar el servicio en WebApi para soportar jsonp. Para ello nos basaremos en la querystring.

Soportando JSONP en WebApi

Que yo sepa WebApi NO tiene soporte directo para jsonp. Por suerte añadirlo es trivial. Basta con usar un MediaTypeFormatter nuevo:

public class JsonpMediaFormatter : JsonMediaTypeFormatter

{

    public JsonpMediaFormatter()

        : base()

    {

        SupportedMediaTypes.Add(DefaultMediaType);

        SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/javascript"));

        MediaTypeMappings.Add(new QueryStringMapping("jsonp", "true",DefaultMediaType));

    }

 

 

    public override Task WriteToStreamAsync(Type type, object value, Stream writeStream, System.Net.Http.HttpContent content, TransportContext transportContext)

    {

        var callback = GetJsonpCallback();

        if (string.IsNullOrEmpty(callback))

            return base.WriteToStreamAsync(type, value, writeStream, content, transportContext);

 

        Encoding encoding = SelectCharacterEncoding(content.Headers);

        return Task.Factory.StartNew(() =>

            {

                var bytes = encoding.GetBytes(string.Format("{0}(", callback));

                writeStream.Write(bytes, 0, bytes.Length);

            }).

            ContinueWith(task =>

                {

                    base.WriteToStreamAsync(type, value, writeStream, content, transportContext);

                }).

                ContinueWith(task =>

                    {

                        var bytes = encoding.GetBytes(");");

                        writeStream.Write(bytes, 0, bytes.Length);

                    });

    }

 

    protected string GetJsonpCallback()

    {

        if (HttpContext.Current.Request.HttpMethod != "GET") return null;

        if (HttpContext.Current.Request.QueryString["jsonp"] != "true") return null;

        return HttpContext.Current.Request.QueryString["callback"] ?? "func_callback";

    }

}

Para registrar este MediaTypeFormatter debemos añadir la siguiente línea en el Application_Start:

GlobalConfiguration.Configuration.Formatters.Add(new JsonpMediaFormatter());

Ahora nuestro JsonpMediaFormatter actuará si la petición tiene un parámetro querystring llamado jsonp y cuyo valor sea true. Además admite otro parámetro llamado callback con el valor de la función callback. Por lo tanto modificamos ahora el tag <script> para que pase esos dos parámetros y también definimos antes la función show_beer:

<script>

    function show_beer(data) {

        alert(data.Name);

    }

</script>

 

<script src="http://localhost:2614/Beers/10?jsonp=true&callback=show_beer"></script>

¡Y ya hemos terminado! Si ahora ejecutamos la página vemos que efectivamente nos hemos saltado la restricción de orígen:

image

¿Por qué digo que JSONP es una brecha en lugar de un agujero en la seguridad? Muy simple… porque está basado en el tag <script> lo que implica que tan solo funciona para el verbo http GET.

Así, aunque JSONP es un parche que nos puede sacar de muchos apuros, era evidente que necesitábamos una manera segura de poder llamar a servicios REST que estuviesen en otro dominio… y la W3C se puso manos a la obra y definió CORS.

CORS

Las ventajas de CORS sobre JSONP son enormes: CORS funciona para todos los verbos HTTP, permite usar XMLHttpRequest así que no tenemos que andar con trapicheos como en JSONP y además es un estándard y no una técnica salida de una mente calenturienta.

Por supuesto tiene sus inconvenientes: tiene que estar soportado por el servidor y por el navegador. Si os vais a http://caniuse.com/#search=CORS podeis ver como p.ej. IE NO SOPORTA CORS hasta la versión 10 (En la versión 8 y 9 soporta un pseudo-CORS a través del objeto XDomainRequest). Es la historia de siempre… 🙁

CORS se basa en las cabeceras HTTP. Básicamente la idea es que el navegador envía una petición con la cabecera http “Origin” que contiene el origen de la aplicación web. El servidor recibe esta petición y si admite dicho orígen devuelve en la respuesta la cabecera “Access-Control-Allow-Origin” con el nombre de los orígenes admitidos.

Volvamos de nuevo al código original que teníamos antes de ver jsonp. Si abro una ventana de Chrome y navego a la aplicación web, donde se hace la llamada con XMLHttpRequest y miro las cabeceras enviadas:

image

Fijaos como el navegador envía la cabecera “Origin”. Por lo tanto Chrome ya está intentando iniciar una negociación CORS, pero como el servidor no le responde con la cabecera Access-Control-Allow-Origin Chrome no procesa la petición y no podemos acceder a la respuesta.

En este punto voy a dejar una cosa bien clara: La petición es enviada por el navegador y por lo tanto es RECIBIDA por el servidor. Lo podéis comprobar poniendo un Breakpoint en el controlador de WebApi y veréis que se llega a él. Pero la respuesta NO ES PROCESADA por el navegador. Otra forma de verlo es usando fiddler:

image

Como podemos ver hay una petición (con su cabecera Origin) y una respuesta. Solo que el navegador nos ignora la respuesta debido a que no hay la cabecera CORS Access-Control-Allow-Origin.

Soporte para CORS en WebApi

De nuevo, que yo sepa, no hay soporte out-of-the-box en WebApi para CORS, aunque por suerte añadir uno básico es trivial. Si para JSONP usábamos un MediaTypeFormatter, para CORS usaremos un Message Handler para conseguirlo:

Nota: El Message Handler aquí mostrado implementa una parte muy pequeña de CORS. Está pensado a modo de información y no para poner en producción. Solo por citar una de sus limitaciones no está preparado para lidiar con las peticiones “preflight” de CORS que se dan en según que escenarios y que no tratamos en este post.

public class CorsMessageHandler : DelegatingHandler

{

    protected async override System.Threading.Tasks.Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)

    {

        if (request.Headers.Contains("Origin"))

        {

            var response = await base.SendAsync(request, cancellationToken);   

            response.Headers.Add("Access-Control-Allow-Origin",

                                    request.Headers.GetValues("Origin"));

            return response;

        }

        return await base.SendAsync(request, cancellationToken);

    }

}

Este MessageHandler es muy sencillo: si la petición contiene la cabecera “Origin” añade la cabecera “Access-Control-Allow-Origin” con el mismo valor que la cabecera Origin.

Tenemos que registrar este Message Handler, en el Application_Start:

GlobalConfiguration.Configuration.MessageHandlers.Add(new CorsMessageHandler());

¡Listos! Hemos terminado. Si ahora navegamos de nuevo a la aplicación web vemos que la llamada Ajax se efectúa sin problemas:

image

Y si miramos en la pestaña network veremos la cabecera Access-Control-Allow-Origin que ahora envía el servidor como respuesta:

image

Para más información, aquí tenéis la especificación de CORS del W3C.

Si buscáis un soporte de CORS realmente completo para WebAPi echad un vistazo a este post de brocakllen: http://brockallen.com/2012/06/28/cors-support-in-webapi-mvc-and-iis-with-thinktecture-identitymodel/

Saludos!

6 comentarios sobre “¿Están tus servicios REST en otro servidor?”

  1. Gran artículo, Eduard.

    Concluyo entonces que JSONP sólo aplica para GET y es sólo un «parche». La solución debe ser CORS que forma parte del estándar (atención al soporte de los navegadores).

    Saludos.

  2. Excelente artículo Eduard…

    Me gustaría hacerte una pregunta, si puedes orientarme… he creado una WebApi simple, a través de la cual obtengo listados de datos sin problemas, realizo búsquedas y obtengo el contenido de un item específico… todo funciona a la perfección… pero lo que no sé como puedo realizar son los procesos de Ingresos de Datos y Actualización de Datos…

    Sucede que hay una columna en mi tabla que puede llegar facilmente a los 4000 caracteres, esto porque almacena texto de cuentos (por ejemplo, Blanca Nieves tiene más de 12000 caracteres)… el contenido del cuento lo trato en otra tabla dividiendo el texto por cantidades de caracteres… por ejemplo para Blanca Nieves tengo el contenido en 4 registros distintos…

    ¿Existe alguna forma de poder realizar Ingreso de datos mediante WebApi con esta cantidad de datos?

    Saludos cordiales…

  3. @MetalTux

    En una API REST se usan los distintos verbos http para las distintas actualizaciones. Generalmente:

    PUT -> Para insertar/modificar datos
    POST -> Para insertar/modificar datos
    DELETE -> Para borrar datos

    Las diferencias entre PUT/POST son bastante filosóficas así que no entraré en ellas. Pero básicamente si quieres realizar un método que haga una alta en la BBDD:

    public int PostData(MyData data) {… }

    Dado que el método empieza por Post, será enrutado mediante el verbo HTTP post. Los datos que recibes son de tipo MyData (ahí tienes las propiedades que necesites, habitualmente el ID y los datos asociados).
    Dado que es una petición POST los datos viajan por el cuerpo de la petición (no en la URL). Pueden viajar codificados de varias maneras, p.ej. mediante application/x-www-form-urlencoded (submit de form) o bien mediante JSOn. WebApi entiende ambas de serie.

    Por lo tanto la idea es:
    1. Lectura de datos -> HTTP GET (Métodos Get*)
    2. Modificación de datos -> HTTP POST/PUT (Métodos Post* y Put*)
    3. Eliminación de datos -> HTTP DELETE (Métodos Delete*)

    Saludos!

Responder a kiquenet Cancelar respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *