Seguramente, los que leeis geeks ya estaréis al tanto de la salida de ASP.NET WebAPI para la siguiente versión de ASP.NET MVC, de hecho, en esta misma comunidad, gente como Jose Maria Aguilar y Eduard Tomás ya han hecho unos cuentos posts interesantísimos sobre el tema, no dejéis pasar la oportunidad de leerlos y aprender de ellos, os lo recomiendo encarecidamente, si me lo permitís. A mi en este post, me gustaría tratar un tema importante cuando nos enfrentamos a hacer un API REST que pueda ser utilizado por otros sitios sin tener que recurrir a JSON padding, por comodidad y por la limitación del verbo a utilizar. Cross-Origin Resource Sharing, usualmente conocido como CORS, es una especificación que permite habilitar de forma confiable el acceso across-domain-boundaries de forma que no tengamos que utilizar jsonp y soportando cualquier tipo de verbo para la operación.
Antes de intentar explicar detenidamente como funciona y como implementar CORS haremos un pequeño ejemplo con Web API que trataremos de consumir desde otro sitio y veremos como este no puede ser consumido directamente, para, posteriormente ir implementando CORS directamente. La idea del ejemplo es extremadamente simple, un método GET y un POST para un supuesto producto.
|
<span style="color: #0000ff">public</span> <span style="color: #0000ff">class</span> ProductController |
|
<span style="color: #0000ff">public</span> IEnumerable<Product> GetProducts() |
|
var products = <span style="color: #0000ff">new</span> List<Product>() |
|
<span style="color: #0000ff">new</span> Product(){Id =1, Name =<span style="color: #006080">"Windows Power Shell in Action"</span>,Description =<span style="color: #006080">"Second Edition. Bruce Payette"</span>}, |
|
<span style="color: #0000ff">new</span> Product(){Id =1, Name =<span style="color: #006080">"Git in Action"</span>,Description =<span style="color: #006080">""</span>} |
|
<span style="color: #0000ff">return</span> products; |
|
<span style="color: #0000ff">public</span> <span style="color: #000000;"><span style="color: #0000ff">HttpResponseMessage<Product></span> PostProduct(Product product)</span> |
|
<blockquote><div id="codeSnippetWrapper"><div id="codeSnippet" style="text-align: left; line-height: 12pt; background-color: #f4f4f4; width: 100%; font-family: 'Courier New', courier, monospace; direction: ltr; color: black; font-size: 8pt; overflow: visible; border-style: none; padding: 0px;"><pre style="text-align: left; line-height: 12pt; background-color: white; margin: 0em; width: 100%; font-family: 'Courier New', courier, monospace; direction: ltr; color: black; font-size: 8pt; overflow: visible; border-style: none; padding: 0px;"><span style="color: #0000ff">return</span> <span style="color: #0000ff">new</span> HttpResponseMessage<Product>(product, HttpStatusCode.OK); |
Una vez hecho esto, y el consiguiente mapeo de la ruta, podríamos desde nuestro sitio web utilizar este API, por ejemplo, el código de la siguiente vista nos mostraría como recuperar y mostrar la lista de productos de este API.
|
<strong><div id=<span style="color: #006080">"products"</span>></strong> |
|
<strong>Products...</strong> |
|
<strong></div></strong> |
|
<strong><script type=<span style="color: #006080">"text/javascript"</span>></strong> |
|
<strong> $.ajax({</strong> |
|
<strong> url: <span style="color: #006080">"http://localhost:1042/api/product/"</span>,</strong> |
|
<strong> type: <span style="color: #006080">"GET"</span>,</strong> |
|
<strong> success: function (data) {</strong> |
|
<strong> $.each(data, function (index, item) {</strong> |
|
<strong> $(<span style="color: #006080">"#products"</span>).after(<span style="color: #006080">"<p>"</span> + item.Name + <span style="color: #006080">"</p>"</span>);</strong> |
|
<strong> error: function (jqXHR, settings, exception) {</strong> |
|
<strong> $(<span style="color: #006080">"#products"</span>).html(<span style="color: #006080">"error loading products..."</span>);</strong> |
|
<strong></script></strong> |
Sin embargo, como muchos ya sabéis, si intentamos hacer esto mismo desde otro sitio no tendremos el resultado esperado, puesto que esa llamada está fuera del dominio del cliente y los navegadores actuales restringen esta funcionalidad. Tal y como comentamos anteriormente, podríamos utilizar JSONP para solventar este problema, sin embargo, aunque es una medida que funcionan tiene varios inconvenientes. En primer lugar implica un trabajo de cliente adicional y en segundo lugar solamente estaría soportado el verbo GET. Para solventar este problema, veremos como funciona CORS y posteriormente como implementarlo en ASP.NET Web API
Como funciona CORS?
CORS se basa en dos peticiones fundamentales, un cross-orgin-request y un preflight-request, los cuales expondremos de una forma sencilla a continuación.
- cross-origin-request consiste básicamente en el envío por parte de quien solicita un recurso compartido de una cabecer Origin, con el valor del cliente que desea consumir ese recurso. Por parte del servidor, se hace la lectura de esa cabecera y se comprueba que puede utilizar un determinado recurso. Una vez comprobado, el servidor en la respuesta, envía una cabecera Access-Control-Allow-Origin con el valor del cliente admitido, valor no case sensitive que tienen que coincidir con el emisor. En realidad, este proceso se podría complicar un poco más incluyendo credenciales, no obstante, nosotros no lo utilizaremos, si alguien quiere ampliar la información puede hacerlo aquí.
- preflight-request es un request utilizado para, a mayores de garantizar el origen , ver que métodos pueden utilizarse.
Implementando CORS en Web API
Todos los que habéis leido a Eduard y José Maria sabréis que entre las bondades de ASP.NET Web API está la extensibilidad, pués bien, una de las características de esta extensibilidad es la de los MessageHandler. Estos elementos, expuestos en WebAPI generalmente por medio de DelegatingHandler nos permiten introducirnos en el pipeline del proceso de una petición HTTP de una forma muy sencilla.
En nuestro caso, empezaremos por crearnos nuestro handler tal y como vemos.
|
<span style="color: #0000ff">public</span> <span style="color: #0000ff">class</span> CORSMessageHandler |
|
<span style="color: #0000ff">protected</span> <span style="color: #0000ff">override</span> Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) |
|
<span style="color: #0000ff">return</span> <span style="color: #0000ff">base</span>.SendAsync(request, cancellationToken); |
Si registramos este handler, tal y como vemos a continuación en nuestro Application_Start, y ponemos un punto de ruptura en el método SendAsync veremos como ya tenemos una manera de “enchufarnos” al procesamiento de una petición a nuestro servicio de Web API.
|
GlobalConfiguration.Configuration.MessageHandlers.Add(<span style="color: #0000ff">new</span> CORSMessageHandler()); |
Para empezar intentaremos resolver el cross-origin-request, si ha leído la especificación, básicamente lo que haría el navegador es incluir en nuestra petición una cabecera Origin, cabecera con el valor del dominio que intenta llamar al API. Para otorgar al dominio como dominio válido, la petición, además de la respuesta debería incluir una cabecera llamada Access-Control-Allow-Origin con el valor de ese dominio válido. Modificando nuestro handler anterior como sigue, ya tenemos, sin ninguna tarea adicional en el cliente este trabajo resuelto, simple y sencillo. Haga la prueba y fíjese como, automáticamente se hace la inclusión de esta cabecera
|
<span style="color: #0000ff">public</span> <span style="color: #0000ff">class</span> CORSMessageHandler |
|
<span style="color: #0000ff">protected</span> <span style="color: #0000ff">override</span> Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) |
|
var corsEnabled = request.Headers |
|
.Where(h => h.Key == <span style="color: #006080">"Origin"</span>) |
|
<span style="color: #0000ff">if</span> (corsEnabled) |
|
<span style="color: #0000ff">return</span> <span style="color: #0000ff">base</span>.SendAsync(request, cancellationToken) |
|
.ContinueWith<HttpResponseMessage>(t => |
|
response.Headers.Add(<span style="color: #006080">"Access-Control-Allow-Origin"</span>, request.Headers.GetValues(<span style="color: #006080">"Origin"</span>)); |
|
<span style="color: #0000ff">return</span> response; |
|
<span style="color: #0000ff">else</span> |
|
<span style="color: #0000ff">return</span> <span style="color: #0000ff">base</span>.SendAsync(request, cancellationToken); |
El segundo de los request especiales de CORS es el preflight, request que en nuestro caso no se produce puesto que no tenemos ninguna operación que no sea GET. Si hubiéramos implementado una operación con un verbo distinto, por ejemplo el POST de nuestra API Rest la primera petición que llegaría al servidor se corresponde con el request de preflight, que si ha visto la especificación se corresponde con un request con el verbo OPTIONS. La respuesta a esta petición debería incluir un pare de cabeceras como especifica la documentación con los métodos y valores especiales de headers aceptados. Una vez hecho el preflight, la llamada se realizará de forma normal. Pues bien, una vez terminada, más o menos, la explicación, la implementación ( un poco ASM ) sería la siguiente:
|
<span style="color: #0000ff">public</span> <span style="color: #0000ff">class</span> CORSMessageHandler |
|
<span style="color: #0000ff">protected</span> <span style="color: #0000ff">override</span> Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) |
|
var corsEnabled = request.Headers |
|
.Where(h => h.Key == <span style="color: #006080">"Origin"</span>) |
|
<span style="color: #0000ff">bool</span> preflight = request.Method == HttpMethod.Options; |
|
<span style="color: #0000ff">if</span> (corsEnabled) |
|
<span style="color: #0000ff">if</span> (preflight) |
|
<span style="color: #0000ff">return</span> Task.Factory.StartNew<HttpResponseMessage>(() => |
|
var response = <span style="color: #0000ff">new</span> HttpResponseMessage(System.Net.HttpStatusCode.OK); |
|
response.Headers.Add(<span style="color: #006080">"Access-Control-Allow-Origin"</span>, request.Headers.GetValues(<span style="color: #006080">"Origin"</span>)); |
|
response.Headers.Add(<span style="color: #006080">"Access-Control-Allow-Headers"</span>, request.Headers.GetValues(<span style="color: #006080">"Access-Control-Request-Headers"</span>)); |
|
response.Headers.Add(<span style="color: #006080">"Access-Control-Allow-Methods"</span>, <span style="color: #006080">"POST"</span>); |
|
<span style="color: #0000ff">return</span> response; |
|
<span style="color: #0000ff">else</span> |
|
<span style="color: #0000ff">return</span> <span style="color: #0000ff">base</span>.SendAsync(request, cancellationToken) |
|
.ContinueWith<HttpResponseMessage>(t => |
|
response.Headers.Add(<span style="color: #006080">"Access-Control-Allow-Origin"</span>, request.Headers.GetValues(<span style="color: #006080">"Origin"</span>)); |
|
<span style="color: #0000ff">return</span> response; |
|
<span style="color: #0000ff">else</span> |
|
<span style="color: #0000ff">return</span> <span style="color: #0000ff">base</span>.SendAsync(request, cancellationToken); |
NOTA: El código anterior es una implementación muy simple y sin refactoring alguno, no debería utilizarse sin trabajarla un poco. Fíjese en el hardcoded de los métodos soportados etc etc…
Bueno, esto parece que ha sido todo, espero que os haya gustado…
Saludos
Unai
Muy buen artículo Unai! No conocía mucho sobre CORS y me has dado algunas ideas para un proyecto.
La verdad es que cada día es más habitual saltarse las restricciones de Cross Domain Calls, tanto en intranets de clientes como el poder montar un api REST en Azure y ser llamada desde otros dominios 🙂
Un saludo
Gracias Luis, me alegro que te sirva..
Saludos
unai
Gran post, Unai!
Desde luego CORS tiene una pinta estupenda, es una forma mucho más simple para saltarse las limitaciones cross-domain que las opciones actuales.
Lo que aún no he conseguido es echarlo a andar desde un cliente IE9, ni siquiera tocándole las opciones de seguridad. Tendré por aquí alguna configuración en «modo paranoico» que me impide conectar 😉 Pero con Chrome va estupendo.
Ah, y gracias por la referencia! 🙂
José, IE9 lo soporta usando XDomainRequest, Opera no lo soporta. No me acuerdo si $.ajax tiene algun flag para decirle que use XDomain, pero sino, podrías utilizar algo como lo siguiente:
http://forum.jquery.com/topic/cross-domain-ajax-and-ie
Mira el código de jkrinsky
Saludos
unai
Gracias por la info, Unai!