Validating incoming JSON [Raw Symfony 4]


One thing I'm very conscious of is that our Behat feature only cares about the happy path:

  Scenario: Can add a new Album
    Given the request body is:
      """
      {
        "title": "Awesome new Album",
        "track_count": 7,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST
    Then the response code is 201

Awesome.

But what happens if the Symfony 4 JSON API consumer - i.e. our JavaScript developer - makes a typo, or submits the form data in some weird format?

It would be kinda handy if we could stop any monkey business.

This is a two part process.

First, we need to ensure our Album entity enforces some validation constraints.

This sounds rather computer science textbook, but in reality it means hey, don't be submitting null, or can you please make sure the dates are within these two acceptable time frames... and that kind of thing.

It's actually pretty easy, and intuitive to use Symfony's Validation Constraints.

Secondly, we will need a way to show these errors to our JSON API consumer. We will tackle that in the next video.

Installing Symfony Validator

We have a new Symfony 4 project based off the symfony/skeleton. This means we do not have the Symfony Validator currently installed.

If you based your project off the symfony/website-skeleton then you should already have the Symfony Validator installed.

To install the Symfony Validator, simply run the command:

composer require symfony/validator

And we're done.

What Are Our Current Constraints?

As it stands right now, can you tell me if we allow null values to be submitted?

Missing fields?

What about empty strings?

Is an empty string a null value?

Can we submit a date to some crazy time in the future, or in the past?

This is not tested. The actual behaviour right now could be anything, and by not having a test we are effectively saying we're ok with that.

We need to add in a whole bunch of extra tests. This is really good for catching edge cases and giving us confidence that our system is behaving as we expect.

In order to do so, we need to make our tests specific to this implementation. That's fine. We just need to make sure we update our Behat setup accordingly.

# behat.yml

default:
    suites:
        default:
            contexts:
                - FeatureContext
                - Imbo\BehatApiExtension\Context\ApiContext
+           filters:
+               tags: "~@symfony_4_edge_case"
+       symfony_4_edge_case:
+           contexts:
+               - FeatureContext
+               - Imbo\BehatApiExtension\Context\ApiContext
+           filters:
+               tags: "@symfony_4_edge_case"
    extensions:
        Imbo\BehatApiExtension:
            apiClient:
                base_uri: http://127.0.0.1:8000

Please watch the video for an explanation of this setup.

I'm going to create a specific new feature for testing Symfony 4 edge cases.

The reason for this is that Symfony Form Errors are... quite verbose. Our JavaScript JSON API implementation will not use the same format. That's why these need to be specific.

touch features/album_symfony_4_edge_case.feature

Yeah, the naming is fun :)

And in this file:

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 |

  @t @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" }
    """

Our mission is to find out what happens currently, decide if this is acceptable, and then update the test to reflect this. It's not TDD, as our tests are not driving our development. We're simply validating behaviour, changing where needed, and effectively documenting how our Symfony 4 JSON API responds to bad circumstances.

Manual Testing

Let's send in our POST request:

curl -X POST \
  http://127.0.0.1:8000/album \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Type: application/json' \
  -d '{
        "title": "",
        "track_count": 7,
        "release_date": "2030-12-05T01:02:03+00:00"
      }'

And what do you get?

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta name="robots" content="noindex,nofollow" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <title>    An exception occurred while executing &#039;INSERT INTO album (title, release_date, track_count) VALUES (?, ?, ?)&#039; with params [null, &quot;2030-12-05 01:02:03&quot;, 7]:

SQLSTATE[23000]: Integrity constraint violation: 1048 Column &#039;title&#039; cannot be null (500 Internal Server Error)
</title>
        <link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAgCAYAAAABtRhCAAADVUlEQVRIx82XX0jTURTHLYPyqZdefQx66CEo80+aYpoIkqzUikz6Z5klQoWUWYRIJYEUGpQ+lIr9U5dOTLdCtkmWZis3rbnC5fw/neYW002307mX/cZvP3/7o1PwwOdh95x7vnf39zvnd29AgBer2xO6DclAXiMqZAqxIiNIN/IYSUS2BPhjmGATchUxI+ADWiRhpWK7HKuHFVBFdmU5YvnI4grFGCaReF/EBH4KsZlGgj2JBTuCYBWRIYF8YoEOJ6wBt/gEs7mBbyOjQXruPLSdOgPCiEiPSUUHDoL8Ug5IUo9B/d5wrt+G7OAKNrODPuVdB6vRCIzN6SdBlpW9RIgk/1FeAXabzRlrUPVCS/JhbmwudztnGeeH9AyXBIwtmM3wLinZJZHifjHw2V+NBoRh+9ixQrbgbnaSIcl7cGea6hoXQbNe7za241oeO5Z0p42M4BV2EqP2D50wo+6HzvwC6C4sApNOR8cmOrtcnhtj2kYRyC9eBvXzKrBZrXSs72kFd1t3MoKVbMekQkEnSNKOO8fac3LpmK6l1TlGtsxmsdKFsecPYgwxst0cwROMYDXboSotg0WLBRqjY51jLYcENElXwW2XJKPydvoI2GN9T8rBtrAArYIUruBJXkFheCQYlCpQP6uk5dAQFQNaUROMSGVQFxLmkoQsxDJrhLbTZ+nvVsERME9MgPJRKV/58AsyomTSzE813WLFvWK++qI0xSfQl8k8Pg46sYRuv5t6dS+4RqxDwaa4BGjYH+NTQvKScIp9+YL/hoZh3jDtLRHtt2C3g6bmhX+CpsFBWg7ilDSPgj0lD2ncr5ev/BP8VvyAJhqVyZeUhPOrEhEFxgEtjft846Z/guQTNT89Q5P9flMLoth4F7808wKtWWKzAwNQHxrh/1vaid2F+XpYTSbQf1XA2McOmOpROnvpvMEA4tSjq1cW0sws2gCYxswY6TKkvzYnJq1NHZLnRU4BX+4U0uburvusu8Kv8iHY7qefkM4IFngJHEOUXmLEPgiGsI8YnlZILit3vSSLRTQe/MPIZva5pshNIEmyFQlCvruJKXPkCEfmePzkphXHdzZNQdoRI9KPlBAxlj/I8U97ERPS5bjGbWDFbEdqHVe5caTBeZZx2H/IMvzeN15yoQAAAABJRU5ErkJggg==
">
        <style>html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}

html {
    /* always display the vertical scrollbar to avoid jumps when toggling contents */
    overflow-y: scroll;
}
body { ...

That error itself is full on. I mean I have heavily truncated this bad boy, down from its 6347 line glory.

Fortunately we don't need to both with most of it, as right there on line 7 we see:

"An exception occurred while executing &#039;INSERT INTO album (title, release_date, track_count) VALUES (?, ?, ?)&#039; with params [null, &quot;2030-12-05 01:02:03&quot;, 7]:

SQLSTATE[23000]: Integrity constraint violation: 1048 Column &#039;title&#039; cannot be null (500 Internal Server Error)"

Right, so this is kinda good.

On the one hand, Doctrine is saying - woah, no way. I'm not saving this.

And why?

Because title is being seen as null.

    /**
     * @ORM\column(type="string")
     */
    private $title;

The implicit default on a @ORM\column annotation is that nullable=false. This tells the DB, via a Doctrine schema update, that this field is not nullable.

Ok, cool - but that error though.

This is really easy to fix. We can define a validation constraint to say that title cannot be blank - NotBlank.

There's a really similar validation constraint - NotNull.

If we used NotNull then blank strings - "" would be acceptable. Note that we'd still get the same Doctrine error though, as our database field cannot be null.

Let's update our title property to be NotBlank:

<?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;

Now if we send in the very same POST request, we get the following:

{
    "status": "error"
}

But there's an oddity. This is kind of good.

We're hitting this:

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

But as we weren't explicit about the status code, we're returning a 200. And that's wrong.

Fortunately our Behat test checks for this.

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 |

  @t @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()
      Expected response code 400, got 200. (Imbo\BehatApiExtension\Exception\AssertionFailedException)
    And the response body contains JSON:    # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      { "status": "error" }
      """

--- Failed scenarios:

    features/album_symfony_4_edge_case.feature:16

1 scenario (1 failed)
5 steps (3 passed, 1 failed, 1 skipped)
0m0.12s (9.70Mb)

It's much easier to see on the console, as the colouring makes it stand out:

    Then the response code is 400           # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
      Expected response code 400, got 200. (Imbo\BehatApiExtension\Exception\AssertionFailedException)

We can fix this:

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

And that's enough to get us a passing test.

The thing is, the test passes but the behaviour isn't very good.

What was the error?

Well, we know what it was because we just manually tested it.

And if you read the test, you can see that it's kind of understandable.

But it would be super nice to show helpful information back, like, hey you cannot submit an empty string for the title field, ok?

If you've ever used Symfony's form component with Twig then you have likely seen validation before and know this is possible. It's just not particularly easy when working with Symfony as a JSON API. But don't worry, we will get onto this specifically in the next video.

An Album Should Have At Least 1 Track

We can add as many tests in as we'd like.

Maybe you don't want duplicate album titles. Maybe an album title must be more than 3 characters long. Maybe track count can't be more than 100.

You may be thinking - hey Chris, why are you skirting the subject of assertions on that releaseDate? Well, because working with Dates and Times in tests is really hard work, and requires a bit of extra stuff. Click here for a tutorial on Testing Datetime in PHP.

Here's the only other edge case I'm going to test:

  @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" }
    """

  @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" }
    """

Right now 0 and -5 are allowable track counts.

We can use another assertion to ensure that track count has to be greater than 0:

    /**
+    * @Assert\GreaterThan(0)
     * @ORM\column(type="integer")
     */
    private $trackCount;

And now all our tests are passing.

I'd encourage you to play around with these validation constraints. They are super useful, and become even more useful as your application's complexity inevitably grows.

Please visit this link if you'd like to watch more on testing the unhappy path.

What would be a really nice improvement here would be to show some helpful error messages as to exactly what's going wrong. Let's get on to that 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