Saving POST data to the database [FOSRESTBundle]
Our Behat test suite requires that we can POST
data into our API in order to setup the Background
of any test we run. This is somewhat unusual, and not something I'd advocate in the real world. However, for our purposes I believe it works quite well, and it forces us to get to the implementation (hint: the fun part) as quickly as possible.
Before we can send in data, we need to define what that data is (our Entity), how we can process and validate the incoming data (via a Symfony Form), and how we can save that data off to the database (using Doctrine).
Given this, there are three things we need immediately:
- The
Album
entity - The
AlbumType
form - The
EntityManager
The Album
entity is almost identical to that from our basic Symfony 4 JSON API implementation. We won't need to implement \JsonSerializable
.
By using the Maker Bundle we can make the entity class stub, and get the associated repository class generated for us for free:
bin/console make:entity Album
created: src/Entity/Album.php
created: src/Repository/AlbumRepository.php
Success!
Next: Add more fields to your entity and start using it.
Find the documentation at https://symfony.com/doc/current/doctrine.html#creating-an-entity-class
Sweet.
We need to add in our class properties (title
, releaseDate
, and trackCount
), and also add in the validation constraints. Again, this is almost identical to our previous implementation:
<?php
// src/Entity/Album.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity(repositoryClass="App\Repository\AlbumRepository")
*/
class Album
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @Assert\NotBlank()
* @ORM\column(type="string")
*/
private $title;
/**
* @var \DateTime|null
* @ORM\column(type="datetime")
*/
private $releaseDate;
/**
* @Assert\GreaterThan(0)
* @ORM\column(type="integer")
*/
private $trackCount;
/**
* @return int
*/
public function getId(): ?int
{
return $this->id;
}
/**
* @return string|null
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @param string $title
*
* @return Album
*/
public function setTitle($title): Album
{
$this->title = $title;
return $this;
}
/**
* @return \DateTime|null
*/
public function getReleaseDate(): ?\DateTime
{
return $this->releaseDate;
}
/**
* @param \DateTime $releaseDate
*
* @return Album
*/
public function setReleaseDate($releaseDate): Album
{
$this->releaseDate = $releaseDate;
return $this;
}
/**
* @return int|null
*/
public function getTrackCount(): ?int
{
return $this->trackCount;
}
/**
* @param int $trackCount
*
* @return Album
*/
public function setTrackCount($trackCount): Album
{
$this->trackCount = $trackCount;
return $this;
}
}
This is not 100% identical to the previous Album
entity implementation, as this time we need not implements \JsonSerializable
.
That's the entity done.
The Album Form Type
I'm going to shamelessly copy / paste the AlbumType
form from the Symfony 4 JSON API implementation:
<?php
// src/Form/AlbumType.php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AlbumType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title')
->add(
'release_date',
DateTimeType::class,
[
'widget' => 'single_text',
'format' => 'yyyy-MM-dd\'T\'HH:mm:ssZZZZZ',
'property_path' => 'releaseDate',
]
)
->add(
'track_count',
NumberType::class,
[
'property_path' => 'trackCount',
]
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => Album::class,
'allow_extra_fields' => true,
'csrf_protection' => false,
]
);
}
}
We already covered the form setup in this video, so please watch that if unsure.
Accessing The Entity Manager (Or Any Other Symfony Service)
To get access to the entityManager
, we will simply inject it into our AlbumController
via the constructor:
<?php
namespace App\Controller;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* @Rest\RouteResource(
* "Album",
* pluralize=false
* )
*/
class AlbumController extends FOSRestController implements ClassResourceInterface
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(
EntityManagerInterface $entityManager
)
{
$this->entityManager = $entityManager;
}
public function postAction(
Request $request
) {
}
}
Unlike in Symfony 2, or Symfony 3, with Symfony 4's autowiring we don't need to do any further configuration or setup. Just inject what you want, and 90% of the time, you're done.
This concludes all the prerequisite tasks needed to allow us to start the Symfony 4 with FOSRESTBundle implementation
What We Had Before
Let's quickly recap the implementation we had from our non-FOSRESTBundle setup:
/**
* @Route("/album", name="post_album", methods={"POST"})
*/
public function post(
Request $request
) {
$data = json_decode(
$request->getContent(),
true
);
$form = $this->createForm(AlbumType::class, new Album());
$form->submit($data);
if (false === $form->isValid()) {
return new JsonResponse(
[
'status' => 'error',
'errors' => $this->formErrorSerializer->convertFormToArray($form),
],
JsonResponse::HTTP_BAD_REQUEST
);
}
$this->entityManager->persist($form->getData());
$this->entityManager->flush();
return new JsonResponse(
[
'status' => 'ok',
],
JsonResponse::HTTP_CREATED
);
}
We started by taking the incoming request and converting it from raw JSON into an associative PHP array.
Next we created the AlbumType
form, and passed in a new Album
entity as the starting point.
We called the form's submit
method, passing in our associative array / form submission.
Via the validation component, the form component would check the various validation constraints set on our Album
entity. If this data was invalid in any way, the form submission would fail. We then had to manually handle the process of converting the form errors into an array. This array could then be serialized as part of our JsonResponse
.
If the form submission passed validation we would persist
this new entity, and immediately flush
/ save the data to the database.
Finally a JsonResponse
would be returned - the body not so important, but the status of 201
/ HTTP created telling our API consumer that the process succeeded.
That's quite a lot of stuff.
And most every POST
request endpoint you create in your JSON API looks, and behaves, somewhat similar.
FOSRESTBundle recognises this, and offers us some shortcuts.
The Same Thing, But With FOSRESTBundle
Here's the starting point for our revised method in full:
<?php
namespace App\Controller;
use App\Entity\Album;
use App\Form\AlbumType;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @Rest\RouteResource(
* "Album",
* pluralize=false
* )
*/
class AlbumController extends FOSRestController implements ClassResourceInterface
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(
EntityManagerInterface $entityManager
) {
$this->entityManager = $entityManager;
}
public function postAction(
Request $request
) {
$form = $this->createForm(AlbumType::class, new Album());
$form->submit($request->request->all());
if (false === $form->isValid()) {
return $this->handleView(
$this->view($form)
);
}
$this->entityManager->persist($form->getData());
$this->entityManager->flush();
return $this->handleView(
$this->view(
[
'status' => 'ok',
],
Response::HTTP_CREATED
)
);
}
}
Straight away things look a little less 'bulky'.
What Happened To json_decode
?
Starting off, we don't have to do the json_decode
.
Why?
We have already seen that the default fos_rest
configuration is doing a lot of stuff for us. One of the most useful things it can do is take the incoming request body and transform it from raw JSON into a nice associative PHP array for us. It does this using Event Listeners, and it can transform more than just raw JSON. Actually the code here is very interesting, so please watch the video were we go into a deeper dive on this.
Thanks to the BodyListener
, the Request $request
object that we now inject will already have set the PHP array representation of the incoming JSON. To access this, we just need to call $request->request->all()
.
Effortless Error Handling
The next major change is that we not only don't need to explicitly convert the Symfony form errors in any way. Instead we can return the $form
object (an instance of Symfony\Component\Form\Form
btw) if things go wrong.
With our current fos_rest.yaml
configuration, this won't yet display as expected. We'll fix this very shortly.
If the form is valid then it's business as usual.
This is a new entity, so persist
/ manage, and flush
/ save it.
The View From Here
One of the nice parts about FOSRESTBundle is it can work with more than just JSON. We might wish to send in XML, and by using the handleView
method all we need to do is work with arrays, and FOSRESTBundle will take care of serializing this data to whatever format the front end / API consumer wishes to work with.
Of course, we can lock our API down to just JSON. Or just XML, if that's your thing. Or we can support both. Or more... and so on.
Given that we can work with more than just one response type, we need to use the View
abstraction if we want to take advantage of this.
As a heads up, you could just return a JsonResponse
here. Maybe it would be easier - if all you want to support is JSON, why bother with a whole extra layer of view abstraction?
// return $this->handleView(
// $this->view(
// [
// 'status' => 'ok',
// ],
// Response::HTTP_CREATED
// )
// );
return new JsonResponse(
[
'status' => 'ok',
],
JsonResponse::HTTP_CREATED
);
I'm going to go with the View setup because it's an interesting and powerful part of FOSRESTBundle. But you can entirely bypass it if you don't want or need the extra functionality.
Whatever your preference on the response, the good new is: that's it for our initial implementation.
Run The Tests
If we run the Behat test now, things fail:
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 |
@t
Scenario: Can add a new Album # features/album.feature:57
Given the request body is: # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
"""
{
"title": "Awesome new Album",
"track_count": 7,
"release_date": "2030-12-05T01:02:03+00:00"
}
"""
When I request "/album" using HTTP POST # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
Then the response code is 201 # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
Expected response code 201, got 500. (Imbo\BehatApiExtension\Exception\AssertionFailedException)
--- Failed scenarios:
features/album.feature:57
1 scenario (1 failed)
4 steps (3 passed, 1 failed)
0m2.41s (10.54Mb)
The problem here is configuration. We haven't told FOSRESTBundle that all we care about is JSON.
Let's fix this in the very next video.