UPDATE: I’ve created a follow-up article that shows PUT and DELETE calls.
Today, I’m gonna talk about something that always caused me a lot of pain, everytime I had to deal with it : Same-origin policy. This thing is simply awful if you have to make HTTP requests from Javascript.
To counter that, you can use JSONP. But only to make GET requests. If you need more than that (POST, PUT, DELETE), you will have to use Cross-Origin Resource Sharing and that’s what I am going to explain in this post. To do this, I will use Angular.js and Sinatra. Let’s get started, shall we ?
Prerequisites
To follow this tutorial, you will need a version of Ruby and the Sinatra gem installed. You will also need a webserver, if you don’t have any check my post about SimpleHTTPServer.
Setup
So here is the basic code nothing fancy, a very very simple index.html, an angular module called MyApp completly empty and a Sinatra app with the basic route /hi coming from the documentation.
HTML
<!doctype html>
<html ng-app="myApp">
<head>
<title>CORS App</title>
</head>
<body>
Cross-Origin Resource Sharing
<script src="http://code.angularjs.org/1.1.5/angular.min.js"></script>
<script src="app.js"></script>
</body>
</html>
JS
var app = angular.module('myApp', []);
Ruby
require 'sinatra'
get '/hi' do
"Hello World!"
end
Just create three files : index.html, app.js and server.rb and Copy/Paste the corresponding code in each file.
Run the code
You can run the Sinatra server with ruby server.rb
and you can use any webserver to access index.html at http://localhost:9000. Indeed, CORS calls won’t work if you use the file url, you need a real domain.
Let’s get started by configuring the Sinatra app (don’t forget to restart Sinatra server) :
Ruby
require 'sinatra'
require 'json'
before do
content_type :json
end
set :protection, false
get '/movie' do
{ result: "Monster University" }.to_json
end
post '/movie' do
{ result: params[:movie] }.to_json
end
Okay, we are ready to go. If you access http://localhost:4567/movie from your browser, you should be able to see the last movie I saw. So let’s make our first javascript HTTP GET call.
CORS Get
HTML
<!doctype html>
<html ng-app="myApp">
<head>
<title>CORS App</title>
</head>
<body ng-controller="MainCtrl">
Cross-Origin Resource Sharing<br/>
<button ng-click="get()">GET</button>
Result : {{result}}
<script src="http://code.angularjs.org/1.1.5/angular.min.js"></script>
<script src="app.js"></script>
</body>
</html>
JS
var app = angular.module('myApp', []);
app.controller('MainCtrl', function($scope, $http) {
$scope.get = function() {
$http.get("http://localhost:4567/movie").success(function(result) {
console.log("Success", result);
$scope.result = result;
}).error(function() {
console.log("error");
});
};
});
Open it in your browser. You should see this beautiful red line saying that you cannot access this resouce due to the Same-origin policy.
XMLHttpRequest cannot load http://localhost:4567/movie. Origin http://localhost:8000 is not allowed by Access-Control-Allow-Origin.
Time to fix that with Cross-origin Resource Sharing !
JS
var app = angular.module('myApp', []);
app.config(function($httpProvider) {
//Enable cross domain calls
$httpProvider.defaults.useXDomain = true;
//Remove the header used to identify ajax call that would prevent CORS from working
delete $httpProvider.defaults.headers.common['X-Requested-With'];
});
app.controller('MainCtrl', function($scope, $http) {
$scope.get = function() {
$http.get("http://localhost:4567/movie").success(function(result) {
console.log("Success", result);
$scope.result = result;
}).error(function() {
console.log("error");
});
};
});
Ruby
require 'sinatra'
require 'json'
before do
content_type :json
headers 'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => ['OPTIONS', 'GET', 'POST']
end
set :protection, false
options '/movie' do
200
end
get '/movie' do
{ result: "Monster University" }.to_json
end
post '/movie' do
{ result: params[:movie] }.to_json
end
Restart Sinatra server and tadaaaa ! Now it’s working \o/ So what did we do ?
Well, first we told the $http module that we were going to send requests to another domain. We also removed the header used by the browser/server to identify our call as XmlHTTPRequest. Then, we enabled CORS on the server by specifying the available HTTP methods and the allowed origins (in our case, any origin *).
You probably noticed that we added a new route on our server :
options '/movie' do
This is part of the Cross-Origin Resource Sharing specification. Before sending a request to another domain, a call with the HTTP method OPTIONS will be fired. The response to this call will determine if CORS is available or not. This response must contain the allowed origins and the available HTTP methods
Security notice
In a production environment, you should not accept any origin of course, you should specify the allowed domain names like this :
headers 'Access-Control-Allow-Origin' => 'http://localhost:9000, http://localhost:8000'
You should also keep the default Sinatra protection enabled. However, you may have to disable the http origin security to make CORS calls work with Sinatra.
set :protection, except: :http_origin
Unfortunately, we are not done yet. Let’s add a post call and see it miserably fail… :
CORS Post
HTML
<!doctype html>
<html ng-app="myApp">
<head>
<title>CORS App</title>
</head>
<body ng-controller="MainCtrl">
Cross-Origin Resource Sharing<br/>
<button ng-click="get()">GET</button>
Result : {{resultGet}}
<br/>
<input ng-model="movie"/><button ng-click="post(movie)">POST</button>
Result : {{resultPost}}
<script src="http://code.angularjs.org/1.1.5/angular.min.js"></script>
<script src="app.js"></script>
</body>
</html>
JS
var app = angular.module('myApp', []);
app.config(function($httpProvider) {
//Enable cross domain calls
$httpProvider.defaults.useXDomain = true;
//Remove the header containing XMLHttpRequest used to identify ajax call
//that would prevent CORS from working
delete $httpProvider.defaults.headers.common['X-Requested-With'];
});
app.controller('MainCtrl', function($scope, $http) {
$scope.get = function() {
$http.get("http://localhost:4567/movie").success(function(result) {
console.log("Success", result);
$scope.resultGet = result;
}).error(function() {
console.log("error");
});
};
$scope.post = function(value) {
$http.post("http://localhost:4567/movie", { 'movie': value }).success(function(result) {
console.log(result);
$scope.resultPost = result;
}).error(function() {
console.log("error");
});
};
});
Yay, we got a new error :
OPTIONS http://localhost:4567/movie Request header field Content-Type is not allowed by Access-Control-Allow-Headers. angular.min.js:106
XMLHttpRequest cannot load http://localhost:4567/movie. Request header field Content-Type is not allowed by Access-Control-Allow-Headers.
So what’s wrong ?
The answer is in the error, we need to add Content-Type to the allowed headers :
headers 'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => ['OPTIONS', 'GET', 'POST'],
'Access-Control-Allow-Headers' => 'Content-Type'
Now, Post requests are working. But the server doest not send back the submitted word. Instead, we receive : Object {result: null}
If we add a trace to check the received parameters on the server, we get, well nothing :
I, [2013-08-14T21:11:04.901188 #28960] INFO -- : Params : {}
That is due to how Angular.js handles the params to be sent with the post. Sinatra does not detect the params. We are going to do it by ourselves.
Ruby
require 'sinatra'
require 'json'
use Rack::Logger
before do
content_type :json
headers 'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => ['OPTIONS', 'GET', 'POST'],
'Access-Control-Allow-Headers' => 'Content-Type'
end
set :protection, false
options '/movie' do
200
end
get '/movie' do
{ result: "Monster University" }.to_json
end
post '/movie' do
begin
params.merge! JSON.parse(request.env["rack.input"].read)
rescue JSON::ParserError
logger.error "Cannot parse request body."
end
{ result: params[:movie], seen: true }.to_json
end
With this, it should work fine.
There is an other way to get the params, but they will be poorly formatted. You can add the following to app.js :
$httpProvider.defaults.headers.post["Content-Type"] = "application/x-www-form-urlencoded";
With this, we tell the server that our params come from a form. This is how Sinatra handles it :
I, [2013-08-14T21:42:05.648475 #30008] INFO -- : Params : {"{\"movie\":\"aaaa\",\"rating\":5,\"comment\":\"Super fun !\"}"=>nil}
As I told you, it’s kinda weird. Well, you can easily clean it up by getting the first key of the hash, parse it and get the params hash, that’s up to you.
Well, now you should be able to setup CORS calls between your client and your backend ! If you have any problem, feel free to contact me or leave a comment. It took me a while to figure out everything and I hope it will save you a lot of time. If you are interested in seeing PUT and DELETE calls, leave a comment and I will add it.
The code is available on Github.
Thanks for reading,
Tibo