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.

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