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 'INSERT INTO album (title, release_date, track_count) VALUES (?, ?, ?)' with params [null, "2030-12-05 01:02:03", 7]:
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'title' 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 'INSERT INTO album (title, release_date, track_count) VALUES (?, ?, ?)' with params [null, "2030-12-05 01:02:03", 7]:
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'title' 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.