GET'ting data from our Symfony 4 API [Raw Symfony 4]


We've done most of the hard work at this point. We have a working test suite, we can POST in new data, validate it, and save it to the database. In this video we're going to implement GET functionality for a single Album.

Let's quickly recap our Behat test scenario for a single Album GET request :

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:
    Given there are Albums with the following details:
      | 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 |

  Scenario: Can get a single Album
    Given I request "/album/1" using HTTP GET
    Then the response code is 200
    And the response body contains JSON:
    """
    {
      "id": 1,
      "title": "some fake album name",
      "track_count": 12,
      "release_date": "2020-01-08T00:00:00+00:00"
    }
    """

We already have our AlbumController, so we only need to add a new method in this file to handle GET requests.

Let's start by doing just that:

    /**
     * @Route("/album/{id}", name="get_album", methods={"GET"})
     */
    public function get($id)
    {
        exit(\Doctrine\Common\Util\Debug::dump($id));
    }

There should be nothing new to you if you've worked with Symfony 2, or Symfony 3.

The only new thing here, if you haven't worked with Symfony before, or are at all unsure, is that we want to get access to the $id in our get controller method. We can do this by making use of the routing placeholder {id}, and then let Symfony take care of passing this argument in for us.

bin/console debug:router
 -------------------------- -------- -------- ------ ----------------------------------- 
  Name                       Method   Scheme   Host   Path                               
 -------------------------- -------- -------- ------ ----------------------------------- 
  get_album                  GET      ANY      ANY    /album/{id}                        
  post_album                 POST     ANY      ANY    /album                             
  healthcheck                ANY      ANY      ANY    /ping                              
  _twig_error_test           ANY      ANY      ANY    /_error/{code}.{_format}           
  _wdt                       ANY      ANY      ANY    /_wdt/{token}                      
  _profiler_home             ANY      ANY      ANY    /_profiler/                        
  _profiler_search           ANY      ANY      ANY    /_profiler/search                  
  _profiler_search_bar       ANY      ANY      ANY    /_profiler/search_bar              
  _profiler_phpinfo          ANY      ANY      ANY    /_profiler/phpinfo                 
  _profiler_search_results   ANY      ANY      ANY    /_profiler/{token}/search/results  
  _profiler_open_file        ANY      ANY      ANY    /_profiler/open                    
  _profiler                  ANY      ANY      ANY    /_profiler/{token}                 
  _profiler_router           ANY      ANY      ANY    /_profiler/{token}/router          
  _profiler_exception        ANY      ANY      ANY    /_profiler/{token}/exception       
  _profiler_exception_css    ANY      ANY      ANY    /_profiler/{token}/exception.css   
 -------------------------- -------- -------- ------ ----------------------------------- 

We can augment this yet further.

As we only want integer values to be requested, let's use a regular expression requirements pattern to restrict the placeholder to match just positive numbers.

    /**
     * @Route(
     *     "/album/{id}",
     *     name="get_album",
     *     methods={"GET"},
     *     requirements={"id"="\d+"}
     * )
     */
    public function get($id)
    {
        exit(\Doctrine\Common\Util\Debug::dump($id));
    }

We could add an extra test to our album_symfony_4_edge_case.feature that checks for some of these unwanted routes:

  @symfony_4_edge_case
  Scenario: Album ID must be numeric
    Given I request "/album/a" using HTTP GET
    Then the response code is 404

  @symfony_4_edge_case
  Scenario: Album ID must be positive
    Given I request "/album/-99" using HTTP GET
    Then the response code is 404

And these tests should be passing without further intervention on our part.

Unfortunately the primary target of our testing is still failing:

php 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"
      }
      """
      The response body does not contain valid JSON data. (InvalidArgumentException)

--- Failed scenarios:

    features/album.feature:15

1 scenario (1 failed)
4 steps (3 passed, 1 failed)
0m0.11s (9.71Mb)

A Repository of Fun

As our router is now helping us make sure only numeric values are making their way through to our controller code, we can now use this $id variable to lookup existing Album data.

To accomplish this task, we will use the AlbumRepository. This was generated for us earlier when we used the Symfony Maker Bundle to make:entity.

We get access to the AlbumRepository just like we get access to any other service in a Symfony 4 application: Dependency Injection.

Again, we will likely want access to the AlbumRepository from not just our get controller method, but from our PUT, PATCH, and DELETE methods too. This said, it makes sense to inject the AlbumRepository in via the constructor, rather than just into this get method.

<?php

namespace App\Controller;

use App\Entity\Album;
use App\Form\AlbumType;
+use App\Repository\AlbumRepository;
use App\Serializer\FormErrorSerializer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class AlbumController extends AbstractController
{
    /**
     * @var EntityManagerInterface
     */
    private $entityManager;
    /**
     * @var FormErrorSerializer
     */
    private $formErrorSerializer;
+   /**
+    * @var AlbumRepository
+    */
+   private $albumRepository;

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

With the AlbumRepository injected and set as a private property on our controller class, we can now use it from our get method to lookup the album matching the given ID.

The AlbumRepository offers us direct access to the various standard Doctrine repository methods:

  • find
  • findBy
  • findOneBy
  • findAll

We will use find, which takes an ID and, all being well, returns us the Album with that ID.

    /**
     * @Route(
     *     "/album/{id}",
     *     name="get_album",
     *     methods={"GET"},
     *     requirements={"id"="\d+"}
     * )
     */
    public function get($id)
    {
//        $album = $this->albumRepository->find($id);
//
//        return new JsonResponse(
//            $album,
//            JsonResponse::HTTP_OK
//        );

        return new JsonResponse(
            $this->albumRepository->find($id),
            JsonResponse::HTTP_OK
        );
    }

The commented out code is exactly the same as the active code. I show both, as I have inlined the find call directly, and this may be confusing. If unsure, know that these two are doing the exact same thing, but the local $album variable is redundant in the first example.

You could make this process even more streamlined by using a ParamConvertor to automatically convert the given ID to the matching Album entity. Feel free to add this in to your project.

And now if we send in a GET request to: 'http://127.0.0.1:8000/album/1', what do we see?

{}

With a 200 / OK status code.

Say what?

Well, just because we're returning a JsonResponse, Symfony won't just go ahead and JSON encode / serialize our Album entity for us.

Again, this is something that both API Platform, and FOSRESTBundle make easy for us. This "just works".

Here, we need to do a little more.

Now, achieving this in PHP isn't so hard.

Symfony can trigger a json_encode on our Album entity, but we need to tell it how to convert an Album to JSON.

To do this, we need to implements \JsonSerializable on our entity:

/**
 * @ORM\Entity(repositoryClass="App\Repository\AlbumRepository")
 */
-class Album
+class Album implements \JsonSerializable
{

In doing this, we are contractually obligated to implement an extra public method: jsonSerializable, which needs to return an array:

    public function jsonSerialize() : array
    {
        return [
        ];
    }

All we do now is fill out exactly what we want to return. In our case this will be everything, but it needn't be. And notice how I change the property names from camel to snake case:

    public function jsonSerialize(): array
    {
        return [
            'id'           => $this->id,
            'title'        => $this->title,
            'release_date' => $this->releaseDate,
            'track_count'  => $this->trackCount,
        ];
    }

Send in the request again and now things look a lot better:

{
    "id": 1,
    "title": "some fake album name",
    "release_date": {
        "date": "2020-01-08 00:00:00.000000",
        "timezone_type": 3,
        "timezone": "UTC"
    },
    "track_count": 12
}

Not quite perfect though yet.

We need to format the date output a bit further:

    public function jsonSerialize(): array
    {
        return [
            'id'           => $this->id,
            'title'        => $this->title,
-           'release_date' => $this->releaseDate,
+           'release_date' => $this->releaseDate->format(\DateTime::ATOM),
            'track_count'  => $this->trackCount,
        ];
    }

Of course, do whatever you like. The use of format(\DateTime::ATOM), should you not be aware, is how PHP converts to the date format desired in our Behat feature. There are others.

And this is enough to satisfy our Behat test suite for our single Album GET request:

php 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"
      }
      """

1 scenario (1 passed)
4 steps (4 passed)
0m0.12s (9.62Mb)

Are We Done?

Whether testing manually, or in an automated fashion then we should at this point be getting a 200 status code, and seeing our data.

But what if we request a resource that doesn't exist?

We know that our Behat Background section sets up 3 Albums for us.

What if we ask for Album 5?

symfony-4-api-get-unexpected

Strange. It's a 200 status code, but with an empty JSON object on the response.

Different languages and implementations handle this differently.

For our Symfony 4 API we need to do two things:

  1. Explicitly check for a null response from our find call
  2. Capture this in Behat

Let's start by telling Behat the definition of the way we want our system to behave:

# album_symfony_4_edge_case.feature

  @symfony_4_edge_case
  Scenario: Album ID must exist
    Given I request "/album/6" using HTTP GET
    Then the response code is 404

Right now this is failing.

To fix this we need to check if the returned value from a call to find is null:

    /**
     * @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;
    }

Internal to this AlbumController, whenever we want to find an Album, we will now use this private method:

    /**
     * @Route(
     *     "/album/{id}",
     *     name="get_album",
     *     methods={"GET"},
     *     requirements={"id"="\d+"}
     * )
     * @param int $id
     *
     * @return JsonResponse
     */
    public function get($id)
    {
        return new JsonResponse(
            $this->findAlbumById($id),
            JsonResponse::HTTP_OK
        );
    }

Ok, onwards to GETting a collection of Albums.

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