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:

symfony-4-json-api-cors-test

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:

symfony-4-json-api-cors-fail

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'

symfony-4-json-api-cors-same-origin

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.

symfony-4-json-api-cors-pass

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.

Episodes

# Title Duration
1 What will our JSON API actually do? 08:46
2 What needs to be in our Database for our Tests to work? 12:32
3 Cleaning up after each Test Run 02:40
4 Docker makes for Easy Databases 09:01
5 Healthcheck [Raw Symfony 4] 07:53
6 Send in JSON data using POST [Raw Symfony 4] 05:33
7 Keep your data nice and tidy using Symfony's Form [Raw Symfony 4] 10:48
8 Validating incoming JSON [Raw Symfony 4] 08:26
9 Nicer error messages [Raw Symfony 4] 06:23
10 GET'ting data from our Symfony 4 API [Raw Symfony 4] 08:11
11 GET'ting a collection of Albums [Raw Symfony 4] 01:50
12 Update existing Albums with PUT [Raw Symfony 4] 05:00
13 Upsetting Purists with PATCH [Raw Symfony 4] 02:39
14 Hitting DELETE [Raw Symfony 4] 02:11
15 How to open your API to the outside world with CORS [Raw Symfony 4] 07:48
16 Getting Setup with Symfony 4 and FOSRESTBundle [FOSRESTBundle] 09:11
17 Healthcheck [FOSRESTBundle] 06:14
18 Handling POST requests [FOSRESTBundle] 08:31
19 Saving POST data to the database [FOSRESTBundle] 09:44
20 Work with XML, or JSON, or Both [FOSRESTBundle] 04:31
21 Going far, then Too Far with the ViewResponseListener [FOSRESTBundle] 03:19
22 GET'ting data from your Symfony 4 API [FOSRESTBundle] 05:58
23 GET'ting a Collection of data from your Symfony 4 API [FOSRESTBundle] 01:27
24 Updating with PUT [FOSRESTBundle] 02:58
25 Partially Updating with PATCH [FOSRESTBundle] 02:15
26 DELETE'ing Albums [FOSRESTBundle] 01:27
27 Handling Errors [FOSRESTBundle] 08:58
28 Introducing the API Platform [API Platform] 08:19
29 The Entry Point [API Platform] 04:30
30 The Context [API Platform] 05:52
31 Healthcheck - Custom Endpoint [API Platform] 05:17
32 Starting with POST [API Platform] 07:08
33 Creating Entities with the Schema Generator [API Platform] 07:38
34 Defining A Custom POST Route [API Platform] 07:31
35 Finishing POST [API Platform] 06:29
36 GET'ting One Resource [API Platform] 02:50
37 GET'ting Multiple Resources [API Platform] 02:59
38 PUT to Update Existing Data [API Platform] 02:19
39 DELETE to Remove Data [API Platform] 01:15
40 No One Likes Errors [API Platform] 03:28