How to open your API to the outside world with CORS [Raw Symfony 4]
We've now got a tested and working Symfony 4 API that allows us to GET
, POST
, PUT
, PATCH
, and DELETE
our Album
resources. There are lots of extra tasks that we could do here, but there's one that I'd immediately recommend we do, and this is one that is a real "gotcha".
During development we do everything on our local machine, often working with a webserver on localhost, or internal IP address. Symfony makes this super easy for us, as we have already seen - bin/console server:start
- and we are away.
Given that we now have a working Symfony 4 API, it makes sense that we'd like to get our shiny new code out there on to the world wide web. Or, to put it another way, we want to ship this code to production.
But there's a problem.
By default, our Symfony 4 API will be open for access only to requests coming from the same point of origin.
This isn't a problem specific to Symfony. This is Cross-Origin Resource Sharing (CORS) hard at work. You could be using Laravel, or Django, or Spring, or raw PHP and still hit this issue.
What this means is that if we have our Symfony 4 API on a sub-domain, e.g. 'https://api.oursite.com', yet we have our front end on 'https://www.oursite.com', then HTTP requests from the front end to the back end will fail:
"Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://api.oursite.com/album/1. (Reason: CORS header 'Access-Control-Allow-Origin' missing)."
Now this is exactly the sort of annoyance that will highly likely catch you out the first time you need to put your API out into production.
The Problem
I don't want you to just take my word for this.
You should see this problem occur, and then we'll cover the fix - which fortunately for us, is really quite simple.
To begin, we will need to edit out local host
file.
sudo vim /etc/hosts
# windows - C:\Windows\System32\Drivers\etc\hosts
Add two new entries:
127.0.0.1 api.oursite.com
127.0.0.1 different.com
It really doesn't matter what addresses you use. Change them to be whatever you'd like.
The key part here is that the two are different domains. This could be a root domain, and a subdomain. Or two different domains, or any other combo.
If unsure, 127.0.0.1
is the local IP address (localhost
/ the loopback address) of your machine. This means any requests for these two domains will never leave your machine.
With our two fake domains, we can now convince the built-in PHP webserver to start serving requests for each domain.
To do this you will need two terminal windows.
In the first:
php -S api.oursite.com:8000 -t public
# or
# bin/console server:start api.oursite.com:8000
PHP 7.2.2-3+ubuntu16.04.1+deb.sury.org+1 Development Server started at Thu Mar 1 09:54:52 2018
Listening on http://api.crv.com:8000
Document root is /path/to/your/symfony-4-json-api/public
Press Ctrl-C to quit.
Note there is no difference between the php -S...
and bin/console server...
end result. The Symfony web server wraps the built in web server, and makes the syntax easier / nicer to work with. It's good to know both.
Now you should be able to send requests in to your Symfony 4 JSON API just as before, but instead of using e.g. http://127.0.0.1:8000/album/1
, you can use http://api.oursite.com:8000/album/1
.
If you use cURL, or Postman, you really won't notice any problem.
Keep this web server running, and now switch to a second new terminal window.
Sending HTTP Requests From JavaScript
Later in this course we will cover some of the more advanced libraries and frameworks that JavaScript offers. But for the moment, we'll do things the most basic way possible.
This may be more verbose, but it means we don't need any external dependencies.
Start by creating a new directory somewhere on your computer. It doesn't matter where:
cd /tmp
mkdir front-end-test
cd front-end-test
touch index.html
Now, into the index.html
file, add the following:
<!DOCTYPE html>
<html>
<body>
<button id="ajaxButton" type="button">Make a request</button>
<script>
(function() {
var httpRequest;
document.getElementById("ajaxButton").addEventListener('click', makeRequest);
function makeRequest() {
httpRequest = new XMLHttpRequest();
if (!httpRequest) {
console.error('Giving up :( Cannot create an XMLHTTP instance');
return false;
}
httpRequest.onreadystatechange = logContents;
httpRequest.open('GET', 'http://api.oursite.com:8000/album/1');
httpRequest.send();
}
function logContents() {
if (httpRequest.readyState === XMLHttpRequest.DONE) {
if (httpRequest.status === 200) {
console.log('success', httpRequest.responseText);
} else {
console.error('There was a problem with the request.', httpRequest);
}
}
}
})();
</script>
</body>
</html>
As a heads up, I borrowed and adapted the bulk of the JavaScript from MDN.
It's not super important to understand this code at this stage. Copy / paste this into your index.html
file. We will work through code that is similar to this very shortly.
From a high level this code sends in a GET
request to our /album/1
endpoint when the only button on the page is clicked.
If the request succeeds, we log out the response to the console.
Should the request fail, we log that to the console also.
Save this file, and then use the built in PHP webserver to run this file as though it were on our different.com
domain:
php -S different.com:8080 -t .
PHP 7.2.2-3+ubuntu16.04.1+deb.sury.org+1 Development Server started at Thu Mar 1 10:39:41 2018
Listening on http://different.com:8080
Document root is /tmp/front-end-test
Press Ctrl-C to quit.
Ok, you should now be able to open http://different.com:8080
in your browser:
Make sure to open up the web developer tools, which I think is ctrl + i in Chrome and Firefox.
The two tabs that are interesting to us here are Console
, and Network
.
Try clicking the button:
The console logs and error, yet the Network
tab will show the request succeeded. It's a strange one alright.
Does This Even Work?
You can validate that this code works just fine when both the front end and back end code are served from the same domain.
Start by shutting down both of the PHP webservers - ctrl + c is your friend.
Now, copy the index.html
file from your 'front-end-test' directory, to your Symfony 4 API's public
directory:
cp /tmp/front-end-test/index.html /path/to/your/symfony-4-json-api/public/blah.html
We need to make an edit to this file:
<!DOCTYPE html>
<html>
<body>
<button id="ajaxButton" type="button">Make a request</button>
<script>
(function() {
var httpRequest;
document.getElementById("ajaxButton").addEventListener('click', makeRequest);
function makeRequest() {
httpRequest = new XMLHttpRequest();
if (!httpRequest) {
console.error('Giving up :( Cannot create an XMLHTTP instance');
return false;
}
httpRequest.onreadystatechange = logContents;
- httpRequest.open('GET', 'http://api.oursite.com:8000/album/1');
+ httpRequest.open('GET', 'http://127.0.0.1:8000/album/1');
httpRequest.send();
}
function logContents() {
if (httpRequest.readyState === XMLHttpRequest.DONE) {
if (httpRequest.status === 200) {
console.log('success', httpRequest.responseText);
} else {
console.error('There was a problem with the request.', httpRequest);
}
}
}
})();
</script>
</body>
</html>
Save and close.
From your Symfony 4 project root, start up the Symfony 4 webserver as normal:
bin/console server:start
And now browse to your new page: 'http://127.0.0.1:8000/blah.html'
Again, this passes because both our front end code, and back end JSON API live on the same origin - 127.0.0.1:8000
.
The Solution
One of the major benefits of creating a standalone JSON API is that it can be used and deployed independently of our front end code.
This means our Symfony 4 JSON API can be used by React, or Angular, or Vue, or a Mobile App, or a Symfony console application, or a Python app, or a Java program... or literally anything that can "speak" HTTP.
Being limited to these things all co-existing on one domain is not what we want.
This exercise has been to see the problem in action.
Fortunately the solution to this problem, for our Symfony 4 sites at least, is to install a single Symfony Bundle - the Nelmio CORS Bundle.
From our Symfony 4 project directory:
composer require nelmio/cors-bundle
Using version ^1.5 for nelmio/cors-bundle
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 1 install, 0 updates, 0 removals
- Installing nelmio/cors-bundle (1.5.4) Loading from cache
Writing lock file
Generating autoload files
Symfony operations: 1 recipe (b1e8b8e5576e3e5e38d7d3dd855f8c42)
- Configuring nelmio/cors-bundle (>=1.5): From github.com/symfony/recipes:master
ocramius/package-versions: Generating version class...
ocramius/package-versions: ...done generating version class
Executing script cache:clear [OK]
Executing script assets:install --symlink --relative public [OK]
Some files may have been created or updated to configure your new packages.
Please review, edit and commit them: these files are yours.
As we're using Symfony 4 with Flex, all the bundle setup has been taken care of for us. Nice.
A new file has been created:
config/packages/nelmio_cors.yaml
The contents of which are:
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
max_age: 3600
paths:
'^/': ~
An addition has been made to our .env
file also:
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN=^https?://localhost:?[0-9]*$
###< nelmio/cors-bundle ###
It really depends on your circumstances as to what value you want to add in here.
If you only want to allow a specific domain, or set of domains to be able to access your JSON API then fill in these domains here:
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN=^http://different.com:8000$
###< nelmio/cors-bundle ###
To allow anyone to access your Symfony 4 API then set a regex that matches on any URL:
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN=^https?://.*?$
###< nelmio/cors-bundle ###
This entire process has no impact on our setup when using the bin/console server:start
/ all on the same origin setup.
It only impacts our earlier demo where we used different domains.
To test this, close down the Symfony 4 webserver:
bin/console server:stop
And go back to your two terminal setup.
Cool, so that's a potential major headache nipped firmly in the bud.
Tidy Up
Be sure to remove any custom entries from your host file.