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?

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:
- Explicitly check for a
null
response from ourfind
call - 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 GET
ting a collection of Albums.