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 GET
ting our individual Album
done.