Nicer error messages [Raw Symfony 4]


In the previous video we wrote some edge case tests that cover what happens if we send in an empty title string, or try to say our hot new Album has -5 tracks. Far out, dude.

Whilst we are catching these gnarly situations, we aren't really helping our Symfony 4 JSON API consumers as we just say: "status is error, kthxbye!"

It would be much nicer if we said yes, this is an error and it's because X, Y, and Z.

Symfony's form has already done about 90% of the hard work for us. It knows what the errors are, and to which fields they apply.

But getting that information from Symfony's form component and converting it in to a nice bit of JSON is... not so easy.

Both FOSRESTBundle, and the Symfony API Platform will take care of this process for you. As we aren't using either of those at this point, we have to handle this problem ourselves.

Getting Errors From Symfony Form

It would be super nice if this "just worked":

        if (false === $form->isValid()) {
            return new JsonResponse(
                [
                    'status' => 'error',
                    'errors' => $form->getErrors(),
                ],
                JsonResponse::HTTP_BAD_REQUEST
            );
        }

But it doesn't:

{
    "status": "error",
    "errors": {}
}
symfony-4-json-api-form-validation-error

Frustrating. The error message is there. $form->getErrors() is a legit method. But without extra help, this won't render out as JSON.

I Just Invented A Round Thing, Which I Call "The Wheel"

I'm not in the habit of reinventing the wheel. I guess that's why I like Symfony, and the vast array of libraries / bundles within the ecosystem. It's also why I like WordPress. For the most part, people much smarter than I have already encountered, and solved (m)any problems I encounter.

This is - thankfully - also the case here.

The FOSRESTBundle crew have already solved this problem. As have the API Platform team.

Each solution is valid. Each is different.

For our needs, the FOSRESTBundle approach is the quickest way to achieve our goals. We'll still need a bit of customisation, but here's our starting point:

<?php

/*
 * This file is part of the FOSRestBundle package.
 *
 * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace FOS\RestBundle\Serializer\Normalizer;

use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Translation\TranslatorInterface;

/**
 * Normalizes invalid Form instances.
 *
 * @author Ener-Getick <egetick@gmail.com>
 */
class FormErrorNormalizer implements NormalizerInterface
{
    private $translator;

    public function __construct(TranslatorInterface $translator)
    {
        $this->translator = $translator;
    }

    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = [])
    {
        return [
            'code' => isset($context['status_code']) ? $context['status_code'] : null,
            'message' => 'Validation Failed',
            'errors' => $this->convertFormToArray($object),
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof FormInterface && $data->isSubmitted() && !$data->isValid();
    }

    /**
     * This code has been taken from JMSSerializer.
     */
    private function convertFormToArray(FormInterface $data)
    {
        $form = $errors = [];

        foreach ($data->getErrors() as $error) {
            $errors[] = $this->getErrorMessage($error);
        }

        if ($errors) {
            $form['errors'] = $errors;
        }

        $children = [];
        foreach ($data->all() as $child) {
            if ($child instanceof FormInterface) {
                $children[$child->getName()] = $this->convertFormToArray($child);
            }
        }

        if ($children) {
            $form['children'] = $children;
        }

        return $form;
    }

    private function getErrorMessage(FormError $error)
    {
        if (null !== $error->getMessagePluralization()) {
            return $this->translator->transChoice($error->getMessageTemplate(), $error->getMessagePluralization(), $error->getMessageParameters(), 'validators');
        }

        return $this->translator->trans($error->getMessageTemplate(), $error->getMessageParameters(), 'validators');
    }
}

Which is quite a lot of code, and also, now hopefully a little more evident as to why our naive attempt failed.

To get this into our project we could either require the entire friendsofsymfony/rest-bundle. Or we could copy / paste this file over to our project, and update the namespace.

I'm going with the second approach, as the idea here is to be as streamlined as possible. We will create an entirely separate implementation that uses Symfony 4 with FOSRESTBundle very shortly.

Here's the version we will use:

<?php

/*
 * This file was copied from the FOSRestBundle package.
 *
 * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
 *
 * For the full copyright and license information, please view the LICENSE
 * file at https://github.com/FriendsOfSymfony/FOSRestBundle/blob/master/LICENSE
 * 
 * Original @author Ener-Getick <egetick@gmail.com>
 */

namespace App\Serializer;

// src/Serializer/FormErrorSerializer.php

use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Translation\TranslatorInterface;

/**
 * Serializes invalid Form instances.
 */
class FormErrorSerializer
{
    private $translator;

    public function __construct(TranslatorInterface $translator)
    {
        $this->translator = $translator;
    }

    public function convertFormToArray(FormInterface $data)
    {
        $form = $errors = [];

        foreach ($data->getErrors() as $error) {
            $errors[] = $this->getErrorMessage($error);
        }

        if ($errors) {
            $form['errors'] = $errors;
        }

        $children = [];
        foreach ($data->all() as $child) {
            if ($child instanceof FormInterface) {
                $children[$child->getName()] = $this->convertFormToArray($child);
            }
        }

        if ($children) {
            $form['children'] = $children;
        }

        return $form;
    }

    private function getErrorMessage(FormError $error)
    {
        if (null !== $error->getMessagePluralization()) {
            return $this->translator->transChoice(
                $error->getMessageTemplate(),
                $error->getMessagePluralization(),
                $error->getMessageParameters(),
                'validators'
            );
        }

        return $this->translator->trans($error->getMessageTemplate(), $error->getMessageParameters(), 'validators');
    }
}

Feel free to write your own implementation if you'd prefer. Even the original code here is borrowed from another library. Like I say, why reinvent the wheel?

How To Use?

Thanks to Symfony 4's autowiring, our new FormErrorSerializer service is now fully operational.

It's likely that we will need to use the FormErrorSerializer in more than just our AlbumController's post method. Therefore, rather than inject into the post method, I will inject into the AlbumController generally, via the constructor:

<?php

namespace App\Controller;

use App\Entity\Album;
use App\Form\AlbumType;
+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;

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

    /**
     * @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' => $form->getErrors(),
+                   '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
        );
    }
}

And POSTing in some bad data now leads to a much improved experience for our JSON API consumer:

{
    "status": "error",
    "errors": {
        "children": {
            "title": {
                "errors": [
                    "This value should not be blank."
                ]
            },
            "release_date": [],
            "track_count": []
        }
    }
}

Sweet.

Updating The Tests

Our tests at this point are all still passing.

This is kinda weird.

Behat will only check what we tell it to check.

We didn't tell it to check for the error messages. All we told it to check for was the "status" key:

  @symfony_4_edge_case
  Scenario: Must have a non-blank title
    Given the request body is:
      """
      {
        "title": "",
        "track_count": 7,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST
    Then the response code is 400
    And the response body contains JSON:
    """
{ "status": "error" }
    """

The status is still error. From Behat's point of view, that hasn't changed.

What we need to do is manually test each scenario, and update our Behat feature accordingly:

Therefore our test becomes:

  @symfony_4_edge_case
  Scenario: Must have a non-blank title
    Given the request body is:
      """
      {
        "title": "",
        "track_count": 7,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST
    Then the response code is 400
    And the response body contains JSON:
    """
    {
        "status": "error",
        "errors": {
            "children": {
                "title": {
                    "errors": [
                        "This value should not be blank."
                    ]
                },
                "release_date": [],
                "track_count": []
            }
        }
    }
    """

And the file in full:

Feature: Provide insight into how Symfony 4 behaves on the unhappy path

  In order to eliminate bad Album data
  As a JSON API developer
  I need to ensure Album data meets expected criteria

  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 |

  @symfony_4_edge_case
  Scenario: Must have a non-blank title
    Given the request body is:
      """
      {
        "title": "",
        "track_count": 7,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST
    Then the response code is 400
    And the response body contains JSON:
    """
    {
        "status": "error",
        "errors": {
            "children": {
                "title": {
                    "errors": [
                        "This value should not be blank."
                    ]
                },
                "release_date": [],
                "track_count": []
            }
        }
    }
    """

  @symfony_4_edge_case
  Scenario: Must have a track count of one or greater
    Given the request body is:
      """
      {
        "title": "My album title",
        "track_count": 0,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST
    Then the response code is 400
    And the response body contains JSON:
    """
    {
        "status": "error",
        "errors": {
            "children": {
                "title": [],
                "release_date": [],
                "track_count": {
                    "errors": [
                        "This value should be greater than 0."
                    ]
                }
            }
        }
    }
    """

  @symfony_4_edge_case
  Scenario: Must have a track count of one or greater
    Given the request body is:
      """
      {
        "title": "My album title",
        "track_count": -5,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST
    Then the response code is 400
    And the response body contains JSON:
    """
    {
        "status": "error",
        "errors": {
            "children": {
                "title": [],
                "release_date": [],
                "track_count": {
                    "errors": [
                        "This value should be greater than 0."
                    ]
                }
            }
        }
    }
    """

Running the tests now:

php vendor/bin/behat --suite symfony_4_edge_case

Feature: Provide insight into how Symfony 4 behaves on the unhappy path

  In order to eliminate bad Album data
  As a JSON API developer
  I need to ensure Album data meets expected criteria

  Background:                                          # features/album_symfony_4_edge_case.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 |

  @symfony_4_edge_case
  Scenario: Must have a non-blank title     # features/album_symfony_4_edge_case.feature:16
    Given the request body is:              # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
      """
      {
        "title": "",
        "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 400           # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
    And the response body contains JSON:    # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      {
          "status": "error",
          "errors": {
              "children": {
                  "title": {
                      "errors": [
                          "This value should not be blank."
                      ]
                  },
                  "release_date": [],
                  "track_count": []
              }
          }
      }
      """

  @symfony_4_edge_case
  Scenario: Must have a track count of one or greater # features/album_symfony_4_edge_case.feature:46
    Given the request body is:                        # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
      """
      {
        "title": "My album title",
        "track_count": 0,
        "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 400                     # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
    And the response body contains JSON:              # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      {
          "status": "error",
          "errors": {
              "children": {
                  "title": [],
                  "release_date": [],
                  "track_count": {
                      "errors": [
                          "This value should be greater than 0."
                      ]
                  }
              }
          }
      }
      """

  @symfony_4_edge_case
  Scenario: Must have a track count of one or greater # features/album_symfony_4_edge_case.feature:76
    Given the request body is:                        # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
      """
      {
        "title": "My album title",
        "track_count": -5,
        "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 400                     # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
    And the response body contains JSON:              # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      {
          "status": "error",
          "errors": {
              "children": {
                  "title": [],
                  "release_date": [],
                  "track_count": {
                      "errors": [
                          "This value should be greater than 0."
                      ]
                  }
              }
          }
      }
      """

3 scenarios (3 passed)
15 steps (15 passed)
0m0.31s (9.92Mb)

Boom.

Not only is our API now more fully tested, but the interesting behaviour is captured and becomes living documentation. I love Behat.

This is a brilliant file to share with your front end developer(s), as it answers a whole bunch of their questions without them having to bother you. Plus, they can easily work with Gherkin as it's so human friendly.

Lots of win.

Ok, this was the hardest part of rolling our own Symfony 4 JSON API. Now we need to tackle the remaining verbs: GET, PUT, PATCH, and DELETE.

Let's get to it.

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