Handling Errors [FOSRESTBundle]


At this point we can successfully GET, POST, PUT, PATCH, and DELETE. However, if things go wrong then our existing tests are going to fail. Let's quickly look at why:

  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 we run this:

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: Must have a non-blank title     # features/album.feature:67
    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": []
              }
          }
      }
      """
      Haystack object is missing the "status" key.

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

      ================================================================================
      = Haystack =====================================================================
      ================================================================================
      {
          "code": 400,
          "message": "Validation Failed",
          "errors": {
              "children": {
                  "title": {
                      "errors": [
                          "This value should not be blank."
                      ]
                  },
                  "release_date": [],
                  "track_count": []
              }
          }
      }
       (Imbo\BehatApiExtension\Exception\ArrayContainsComparatorException)

--- Failed scenarios:

    features/album.feature:67

1 scenario (1 failed)
5 steps (4 passed, 1 failed)
0m0.13s (9.84Mb)

Ok, pretty hard to read on here. A screenshot is a little clearer:

behat-symfony-4-fosrestbundle-put-error

It's not super crucial to be able to read this.

The gist is that we expect this:

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

But we currently get this:

{
    "code": 400,
    "message": "Validation Failed",
    "errors": {
        "children": {
            "title": {
                "errors": [
                    "This value should not be blank."
                ]
            },
            "release_date": [],
            "track_count": []
        }
    }
}

It's almost identical, but not quite.

Now, we could either update the test - which I don't want to do - or we could make FOSRESTBundle serialize errors in a manner that fits our needs. That sounds better. Let's do that.

Norm Peterson

If you remember back, in our Symfony 4 JSON API without FOSRESTBundle, when it came to converting a Form with errors into an array, we had "a hard time".

Well, we would have had a hard time, but actually we stole the implementation for such a task from FOSRESTBundle.

And FOSRESTBundle largely borrow their implementation from JMSSerializer.

And JMSSerializer got the implementation from... no I kid.

The point is, out of the box, FOSRESTBundle will happily transform - or more accurately normalize - the errors from our Symfony form into a plain old PHP array. We don't even need to do anything, or enable anything. It just works.

This works because FormErrorNormalizer is tagged as a serializer.normalizer:

<!-- vendor/friendsofsymfony/rest-bundle/Resources/config/serializer.xml -->

<!-- Normalizes FormInterface when using the symfony serializer -->
<service id="fos_rest.serializer.form_error_normalizer"
         class="FOS\RestBundle\Serializer\Normalizer\FormErrorNormalizer"
         public="false">
    <argument type="service" id="translator" />
    <tag name="serializer.normalizer" priority="-10" />
</service>

There are a bunch of other normalizers brought in automatically when we install the serializer. You can find these by searching for anything tagged with <tag name="serializer.normalizer". For a better understanding of this concept, please watch the video.

And later on, during code execution, our Form errors will travel through the FOSRESTBundle's ViewHandler::initResponse method, whereby a call to the serializer is made:

    /**
     * Initializes a response object that represents the view and holds the view's status code.
     *
     * @param View   $view
     * @param string $format
     *
     * @return Response
     */
    private function initResponse(View $view, $format)
    {
        $content = null;
        if ($this->isFormatTemplating($format)) {
            $content = $this->renderTemplate($view, $format);
        } elseif ($this->serializeNull || null !== $view->getData()) {
            $data = $this->getDataFromView($view);

            if ($data instanceof FormInterface && $data->isSubmitted() && !$data->isValid()) {
                $view->getContext()->setAttribute('status_code', $this->failedValidationCode);
            }

            $context = $this->getSerializationContext($view);
            $context->setAttribute('template_data', $view->getTemplateData());

            // **************** this bit ************
            $content = $this->serializer->serialize($data, $format, $context);
            // **************************************
        }

        $response = $view->getResponse();
        $response->setStatusCode($this->getStatusCode($view, $content));

        if (null !== $content) {
            $response->setContent($content);
        }

        return $response;
    }

As you'll see in the video, the FormErrorNormalizer is the first of eight normalizers that the serializer would check. Each is checked using the supportsNormalization method - required as part of implementing the NoramlizerInterface:

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

As FormErrorNormalizer is checked first, and its supportsNormalization method returns true, no other normalizers are checked.

This is good. We have found the code we need to work with in order to reach the outcome we desire.

Our Customised Normalizer

There's no point reinventing the wheel.

Inheritance can help us here.

If we create our own CustomFormErrorNormalizer which extends FormErrorNormalizer then we can get all the benefits of the existing implementation, and just tweak the output slightly.

I'm going to create a new file: src/App/Serializer/Normalizer/FormErrorNormalizer.php.

And here's the starting contents:

<?php

// src/App/Serializer/Normalizer/FormErrorNormalizer.php

namespace App\Serializer\Normalizer;

use FOS\RestBundle\Serializer\Normalizer\FormErrorNormalizer as FosRestFormErrorNormalizer;

class FormErrorNormalizer extends FosRestFormErrorNormalizer
{
}

As I want to reuse the name FormErrorNormalizer, and that name is already in use by FOSRESTBundle, I need to use ... as ....

This simply allows me to rename the dependency to whatever I like.

Then I set my class to extends FosRestFormErrorNormalizer.

As this new class extends an existing class that's already setup and tagged, everything continues to work. However, now this more specific implementation will be used instead of the class provided by FOSRESTBundle.

At the moment we have no overridden methods, so we just fall back to whatever logic already exists in the parent class.

We can test override the normalize method now, switching the logic to meet our requirements:

<?php

namespace App\Serializer\Normalizer;

use FOS\RestBundle\Serializer\Normalizer\FormErrorNormalizer as FosRestFormErrorNormalizer;

class FormErrorNormalizer extends FosRestFormErrorNormalizer
{
    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = array())
    {
        return [
            'status' => 'error',
            'errors' => parent::normalize($object, $format, $context)['errors'],
        ];
    }
}

I'm not entirely sure I'm happy with this. It feels very error prone. What if the errors key doesn't exist on the resulting array returned by a call to the parent's normalize method?

What if a call to the parent's normalize method doesn't even return an array?

Yeah... this is too trivial an implementation for the real world. We would need a try / catch, and some defaults / fallback values.

However, I am ok with this for our demo. This shows how to hook into the normalization process. We meet our test requirements:

vendor/bin/behat features/album.feature --tags=t

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.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: Must have a non-blank title     # features/album.feature:67
    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": []
              }
          }
      }
      """

1 scenario (1 passed)
5 steps (5 passed)
0m0.14s (9.80Mb)

Our Symfony 4 with FOSRESTBundle JSON API now behaves exactly as the Symfony 4 JSON API without any dependencies project did.

12 scenarios (12 passed)
55 steps (55 passed)
0m2.30s (10.16Mb)

We know the internals are different, but the outcomes are the same.

That's our FOSRESTBundle implementation done. In the next section we will repeat this process again, this time using API Platform.

Episodes