GET'ting data from your Symfony 4 API [FOSRESTBundle]


We can now POST data into our Symfony 4 and FOSRESTBundle API. We've seen how we can send in data either as JSON, or XML, and both are supported just fine. Now let's GET some data back out of our API.

When we generated the Album entity, Symfony's Maker Bundle also created src/Repository/AlbumRepository for us.

The AlbumRepository, via extends (inheritance), gives us access to all the standard Doctrine helper methods like find, findAll, findOneBy, and so on.

We'll only need the find method.

Our GET route will expect the API consumer to pass in an ID via the URL.

We'll take the requested $id and use this for querying for Album entities.

Then, all we need to do is return this object from our controller method, wrapped in a View.

Here's the test:

vendor/bin/behat features/album.feature --tags=t

Feature: Provide a consistent standard JSON API endpoint

  In order to build interchangeable front ends
  As a JSON API developer
  I need to allow Create, Read, Update, and Delete functionality

  Background:                                                   # features/album.feature:7
    Given there are Albums with the following details:          # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
      | title                              | track_count | release_date              |
      | some fake album name               | 12          | 2020-01-08T00:00:00+00:00 |
      | another great album                | 9           | 2019-01-07T23:22:21+00:00 |
      | now that's what I call Album vol 2 | 23          | 2018-02-06T11:10:09+00:00 |
    And the "Content-Type" request header is "application/json" # Imbo\BehatApiExtension\Context\ApiContext::setRequestHeader()

  @t
  Scenario: Can get a single Album            # features/album.feature:16
    Given I request "/album/1" using HTTP GET # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 200             # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
      Expected response code 200, got 404. (Imbo\BehatApiExtension\Exception\AssertionFailedException)
    And the response body contains JSON:      # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      {
        "id": 1,
        "title": "some fake album name",
        "track_count": 12,
        "release_date": "2020-01-08T00:00:00+00:00"
      }
      """

--- Failed scenarios:

    features/album.feature:16

1 scenario (1 failed)
5 steps (3 passed, 1 failed, 1 skipped)
0m0.29s (9.82Mb)

It makes sense that this request returns a 404. We haven't implemented a GET method yet. Let's do this now.

Handling GET Requests

As we saw in the previous videos on FOSRESTBundle, we only need to follow the conventions and FOSRESTBundle will do most of the hard work for us.

We want to handle GET requests, so we need a getAction.

    public function getAction(string $id)
    {
    }

Checking the router:

bin/console debug:router
 -------------------------- -------- -------- ------ ----------------------------------- 
  Name                       Method   Scheme   Host   Path                               
 -------------------------- -------- -------- ------ ----------------------------------- 
  get_album                  GET      ANY      ANY    /album/{id}                        
  post_album                 POST     ANY      ANY    /album                             
 -------------------------- -------- -------- ------ ----------------------------------- 

Again, I have removed much of the extra noise here.

There's something really cool happening here:

As this route will require an $id property, we only need to add this as an argument to the getAction controller method and FOSRESTBundle will add a placeholder - {id} - on the generated route.

All we need to do is make a call to find the given $id, and return the result:

<?php

namespace App\Controller;

use App\Repository\AlbumRepository;
// etc

/**
 * @Rest\RouteResource(
 *     "Album",
 *     pluralize=false
 * )
 */
class AlbumController extends FOSRestController implements ClassResourceInterface

    /**
     * @var EntityManagerInterface
     */
    private $entityManager;
    /**
     * @var AlbumRepository
     */
    private $albumRepository;

    public function __construct(
        EntityManagerInterface $entityManager,
        AlbumRepository $albumRepository
    ) {
        $this->entityManager       = $entityManager;
        $this->albumRepository     = $albumRepository;
    }

    public function getAction(string $id)
    {
        return $this->view(
            $this->albumRepository->find($id)
        );
    }

This looks like a lot of extra stuff. Really all we have done is inject the AlbumRepository via the constructor, and then use this in the getAction.

Things look good, right? This seems like we should be done. Well, let's send in our GET test and find out:

vendor/bin/behat --tags=t

Feature: Provide a consistent standard JSON API endpoint

  In order to build interchangeable front ends
  As a JSON API developer
  I need to allow Create, Read, Update, and Delete functionality

  Background:                                          # features/album.feature:7
    Given there are Albums with the following details: # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
      | title                              | track_count | release_date              |
      | some fake album name               | 12          | 2020-01-08T00:00:00+00:00 |
      | another great album                | 9           | 2019-01-07T23:22:21+00:00 |
      | now that's what I call Album vol 2 | 23          | 2018-02-06T11:10:09+00:00 |

  @t
  Scenario: Can get a single Album            # features/album.feature:15
    Given I request "/album/1" using HTTP GET # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 200             # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
    And the response body contains JSON:      # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      {
        "id": 1,
        "title": "some fake album name",
        "track_count": 12,
        "release_date": "2020-01-08T00:00:00+00:00"
      }
      """
      Haystack object is missing the "track_count" key.

      ================================================================================
      = Needle =======================================================================
      ================================================================================
      {
          "id": 1,
          "title": "some fake album name",
          "track_count": 12,
          "release_date": "2020-01-08T00:00:00+00:00"
      }

      ================================================================================
      = Haystack =====================================================================
      ================================================================================
      {
          "id": 1,
          "title": "some fake album name",
          "releaseDate": "2020-01-08T00:00:00+00:00",
          "trackCount": 12
      }
       (Imbo\BehatApiExtension\Exception\ArrayContainsComparatorException)

--- Failed scenarios:

    features/album.feature:15

1 scenario (1 failed)
4 steps (3 passed, 1 failed)
0m1.03s (9.66Mb)

Though the output isn't particularly easy to read when pasted into this web page, when viewed from the console it is a little easier to work with thanks to the output colouring.

The problem being that we expected e.g. track_count, but instead we get back trackCount. Likewise for release_date / releaseDate.

This is an issue with serialization. Although we did install symfony/serializer-pack, we haven't provided any custom configuration. There is a provided serializer for converting from camel case to snake case. We need to explicitly enable this:

# config/packages/framework.yaml

framework:

    # other stuff ...

    serializer:
        name_converter: 'serializer.name_converter.camel_case_to_snake_case'

An alternative to this is to implements \JsonSerializable on your entities, or anything you wish to output via your Symfony 4 JSON API. Whilst the serializer is pretty much "set and forget", implementing JsonSerializable gives you more flexibility. You can combine both though, so you have the best of both worlds.

At this point we now have a passing test:

vendor/bin/behat features/album.feature --tags=t

Feature: Provide a consistent standard JSON API endpoint

  In order to build interchangeable front ends
  As a JSON API developer
  I need to allow Create, Read, Update, and Delete functionality

  Background:                                                   # features/album.feature:7
    Given there are Albums with the following details:          # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
      | title                              | track_count | release_date              |
      | some fake album name               | 12          | 2020-01-08T00:00:00+00:00 |
      | another great album                | 9           | 2019-01-07T23:22:21+00:00 |
      | now that's what I call Album vol 2 | 23          | 2018-02-06T11:10:09+00:00 |
    And the "Content-Type" request header is "application/json" # Imbo\BehatApiExtension\Context\ApiContext::setRequestHeader()

  @t
  Scenario: Can get a single Album            # features/album.feature:16
    Given I request "/album/1" using HTTP GET # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 200             # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
    And the response body contains JSON:      # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      {
        "id": 1,
        "title": "some fake album name",
        "track_count": 12,
        "release_date": "2020-01-08T00:00:00+00:00"
      }
      """

1 scenario (1 passed)
5 steps (5 passed)
0m0.13s (9.71Mb)

We're almost done. But we need to check what happens if you request a bad ID?

Let's say we send a GET request to http://api.oursite.com:8000/album/999.

Well, it's not particularly visually interesting, but the outcome is odd:

We get a response with the status code of 204 / no content.

I'm not quite sure I understand the reasoning behind this. Even so, it's not the behaviour we want. The behaviour we do want is to return a 404. This is the same as in our plain old Symfony 4 JSON API build.

I'm going to add in a private method to the AlbumController class:

    /**
     * @param $id
     *
     * @return Album|null
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
     */
    private function findAlbumById($id)
    {
        $album = $this->albumRepository->find($id);

        if (null === $album) {
            throw new NotFoundHttpException();
        }

        return $album;
    }

And rather than using direct calls to the AlbumRepository, instead any calls to find an Album by ID will pass through this private method.

This way, if we ask for some Album that doesn't exist, we get the expected 404:

{
    "error": {
        "code": 404,
        "message": "Not Found",
        "exception": [
            {
                "message": "",
                "class": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException",
                "trace": [
                    {
                        "namespace": "",
                        "short_class": "",
                        "class": "",
                        "type": "",
                        "function": "",
                        "file": "/tmp/symfony-4-fos-rest-api/src/Controller/AlbumController.php",
                        "line": 93,
                        "args": []
                    },
                    {
                        "namespace": "App\\Controller",
                        "short_class": "AlbumController",
                        "class": "App\\Controller\\AlbumController",
                        "type": "->",
                        "function": "findAlbumById",
                        "file": "/tmp/symfony-4-fos-rest-api/src/Controller/AlbumController.php",
                        "line": 45,
                        "args": [
                            [
                                "string",
                                "999"
                            ]
                        ]
                    },
                    {
                        "namespace": "App\\Controller",
                        "short_class": "AlbumController",
                        "class": "App\\Controller\\AlbumController",
                        "type": "->",
                        "function": "getAction",
                        "file": "/tmp/symfony-4-fos-rest-api/vendor/symfony/http-kernel/HttpKernel.php",
                        "line": 149,
                        "args": [
                            [
                                "string",
                                "999"
                            ]
                        ]
                    },
                    {
                        "namespace": "Symfony\\Component\\HttpKernel",
                        "short_class": "HttpKernel",
                        "class": "Symfony\\Component\\HttpKernel\\HttpKernel",
                        "type": "->",
                        "function": "handleRaw",
                        "file": "/tmp/symfony-4-fos-rest-api/vendor/symfony/http-kernel/HttpKernel.php",
                        "line": 66,
                        "args": [
                            [
                                "object",
                                "Symfony\\Component\\HttpFoundation\\Request"
                            ],
                            [
                                "integer",
                                1
                            ]
                        ]
                    },
                    {
                        "namespace": "Symfony\\Component\\HttpKernel",
                        "short_class": "HttpKernel",
                        "class": "Symfony\\Component\\HttpKernel\\HttpKernel",
                        "type": "->",
                        "function": "handle",
                        "file": "/tmp/symfony-4-fos-rest-api/vendor/symfony/http-kernel/Kernel.php",
                        "line": 190,
                        "args": [
                            [
                                "object",
                                "Symfony\\Component\\HttpFoundation\\Request"
                            ],
                            [
                                "integer",
                                1
                            ],
                            [
                                "boolean",
                                true
                            ]
                        ]
                    },
                    {
                        "namespace": "Symfony\\Component\\HttpKernel",
                        "short_class": "Kernel",
                        "class": "Symfony\\Component\\HttpKernel\\Kernel",
                        "type": "->",
                        "function": "handle",
                        "file": "/tmp/symfony-4-fos-rest-api/public/index.php",
                        "line": 37,
                        "args": [
                            [
                                "object",
                                "Symfony\\Component\\HttpFoundation\\Request"
                            ]
                        ]
                    }
                ]
            }
        ]
    }
}

Oh my.

It's JSON at least :)

Actually this isn't so bad. We see a JSON representation of our stack trace because we're in development mode.

Switch to prod:

# .env

###> symfony/framework-bundle ###
APP_ENV=prod
# ...

Now send in the same request:

{
    "error": {
        "code": 404,
        "message": "Not Found"
    }
}

Ok, for me that's good enough.

We can edit this error message - we'll look at a way to do this a little later on. For now, that's GETting our individual Album done.

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