Sunday, April 27, 2014

ASP.NET Web API 2:: solving HTTP 'OPTIONS' command problem when using custom header and Cross-Origin requests from a web app to web API


Apri 27th, 2014

Technologies stack:

Tools:

Context:

I’m working on a small SPA (single page application) that will consume a web API (also build in house ) that will be hosted on a different site than where the SPA is being served from.  

Taking the highest priority feature, I decided to build it out vertically through the technology stack instead of first building out the entire web service and then SPA.  By doing so, it helps me discover issues between client and service early on before I go too far in the wrong direction and not to mention it keeps me focused on the priority of the features.

Knowing that the web API will be hosted on a separate site, I enabled cross origin request globally as follows:
public static class WebApiConfig
{
   
public static void Register(HttpConfiguration config)
   {
       
var cors = new EnableCorsAttribute("*", "*", "*");
       config.
EnableCors(cors);
       
// ...
   }
}
With the first action returning data to the expected request using Chrome Dev HTTP client tool.  I then turned my attention to the SPA and built out a request to the abovementioned action.  (omitting a lot of details for conciseness).  Surprisingly, I ran into an issue when the SPA made a request to the web API and it was returning HTTP status 500 (server internal error).

The Environment:

Before I go to describing the problem, let me share my dev environment:
The SPA:
  • My spa is build on AngularJS framework with Restangular as means to call the API.  The project resides in WebStorm and it is running on: http://localhost:63342/Velocity/www/index.html
Web API
  • The API is built using ASP.NET Web API 2 in Visual Studio.  It is running on  http://localhost:63200/api

The Issue:

As I mentioned, when Restangular calls the API’s action, it is returning a status 500 indicating that the service just blew up.  Below is my Angular factory and controller that makes the call to the API:

angular.module(Velocity.services', [])
.factory('Calls', ['$http', 'Settings', 'Restangular',
    function ($http, Settings, Restangular) {
           console.log(Velocity.services.calls');
        var config = {};
           Settings.get(function (setting) {
               config = setting;
        });
        var calls = [];
        return {
               all: function(parseCalls, failedHandler, refresh) {
                   this.setHeaders();
                   Restangular
           .one("GetAll")
                       .get({ 'refresh': refresh })
                       .then(function(response) {
                        if (typeof response !== 'undefined' && response) {
                            if (response.Success) parseCalls(response.ServiceCalls);
                            else alert(response.Message);
                        }
                       },
                        function(reason) {
                           failedHandler(JSON.stringify(reason));
                        }
                       );},
               setHeaders: function() {
                   var httpConfig = {
                       headers: {
                        'Token': config.token,
                        'Accept': 'application/json'
                       }
                   };
                   Restangular.setBaseUrl(config.host + '/ServiceCalls');
                   Restangular.setDefaultHeaders(httpConfig.headers);
            }
        };
    }])
.controller('CallsCtrl', function ($scope, $state, Calls) {
       $scope.calls = [];
       $scope.initialized = false;
    $scope.init = function () {
        if (this.initialized) return;
        this.getCallList(false);
           $scope.initialized = true;
    };
       $scope.getCallList = function (refresh) {
           Calls.all(
            //success
            function (calls) {
                    $scope.calls = calls;
            },
            //fail
            function (message) {
                   alert("Failed to retrieve data from server: \r" + message);
            },
            //refresh
            true
        );
    };
       $scope.init();
});
To see what in the world is going on over the wire, I turned on Fiddler to watch the traffic.  To my surprise, I found whenever the SPA made the call to get data, the browser is sending an HTTP command “OPTIONS” instead of the expected ‘GET’ as follows:

OPTIONS http://localhost:63200/api/ServiceCalls/GetAll?refresh=false HTTP/1.1
Host: localhost:63200
Connection: keep-alive
Cache-Control: no-cache
Pragma: no-cache
Access-Control-Request-Method: GET
Origin: http://localhost:63342
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.116 Safari/537.36
Access-Control-Request-Headers: accept, token
Accept: */*
Referer: http://localhost:63342/Velocity/www/index.html
Accept-Encoding: gzip,deflate,sdch
Accept-Language: en-US,en;q=0.8

After reading some posts and articles online, it turned out that the browser was asking API service for permission to use two unknown headers: accept and token.  It is blowing up the API because it is not handling the OPTIONS command at all.

The solution is then to fulfill the access control request.

The Solution:

Asp.Net Web API provides this nice facility called HTTP message handler that can be used to intercept the request and handle the HTTP ‘Options’ command properly.
   public class MessageHandler1 : DelegatingHandler
{
   protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        //if the command is: 'OPTIONS'
        if (request.Method == HttpMethod.Options)
        {
            var response = request.CreateResponse(HttpStatusCode.OK);
            //allow cross origin
               response.Headers.Add("Access-Control-Allow-Origin", "*");
            //allow all http methods
               response.Headers.Add("Access-Control-Allow-Methods", "*");
            //below the two headers that I want to allow
               response.Headers.Add("Access-Control-Allow-Headers", "token");
            //age of the access control
               response.Headers.Add("Access-Control-Max-Age", "360000");
            return await Task.Factory.StartNew(() => response, cancellationToken);
        }
        //otherwise let the request pass through
        return await base.SendAsync(request, cancellationToken);
    }

This message handler needs to be registered with the asp.net global configuration:
public static class WebApiConfig
{
   
public static void Register(HttpConfiguration config)
   {
       config.
MessageHandlers.Add(new MessageHandler1());
   }
}
Now when I run the SPA, I see the browser making two requests, first with the ‘OPTIONS’ command and it receives from the server the following response granting the SPA to use this new header and access with cross origin.  The following is the response to the OPTIONS request:
HTTP/1.1 200 OK
Cache-Control: no-cache
Pragma: no-cache
Expires: -1
Server: Microsoft-IIS/8.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: token
Access-Control-Max-Age: 360000
X-AspNet-Version: 4.0.30319
X-SourceFiles: =?UTF-8?B?QzpcVEZTLVJSLVRGUzAxXE9tZWdhXERldlxPbWVnYS5OZXRcT21lZ2EuTmV0LldlYkFwaVxhcGlcU2VydmljZUNhbGxzXEdldEFsbA==?=
X-Powered-By: ASP.NET
Date: Sun, 27 Apr 2014 22:13:44 GMT
Content-Length: 0

The second request is the actual API request and it returns the data as expected:
GET http://localhost:63200/api/ServiceCalls/GetAll?refresh=false HTTP/1.1
Host: localhost:63200
Connection: keep-alive
Cache-Control: no-cache
Pragma: no-cache
Accept: application/json
Origin: http://localhost:63342
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.116 Safari/537.36
Token: 1a2b7f586-32f2
Referer: http://localhost:63342/Velocity/www/index.html
Accept-Encoding: gzip,deflate,sdch
Accept-Language: en-US,en;q=0.8

Problem solved.  When I was looking for a solution, I didn’t find an exact solution so I thought I’d share this in hope that it will help those who will run across the same issue.

1 comment:

About Cullen

My photo
Christian, Father, Software Developer/Architect who enjoys technology and using it to make people's lives easier!

Followers