Buy

Sending back Validation Errors

Time to add validation errors and get this test passing. First, add the validation. Open the Programmer class. There's no validation stuff here yet, so we need the use statement for it. I'll use the NotBlank class directly, let PhpStorm auto-complete that for me, then remove the last part and add as Assert:

190 lines src/AppBundle/Entity/Programmer.php
... lines 1 - 6
use Symfony\Component\Validator\Constraints as Assert;
... lines 8 - 190

That's a little shortcut to get that use statement you always need for validation.

Now, above username, add @Assert\NotBlank with a message option. Go back and copy the clever message and paste it here:

190 lines src/AppBundle/Entity/Programmer.php
... lines 1 - 15
class Programmer
{
... lines 18 - 26
/**
... lines 28 - 31
* @Assert\NotBlank(message="Please enter a clever nickname")
*/
private $nickname;
... lines 35 - 188
}

Handling Validation in the Controller

Ok! That was step 1. Step 2 is to go into the controller and send the validation errors back to the user. We're using forms, so handling validation is going to look pretty much identical to how it looks in traditional web forms.

Check out that processForm() function we created in episode 1:

145 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 15
class ProgrammerController extends BaseController
{
... lines 18 - 136
private function processForm(Request $request, FormInterface $form)
{
$data = json_decode($request->getContent(), true);
$clearMissing = $request->getMethod() != 'PATCH';
$form->submit($data, $clearMissing);
}
}

All this does is json_decode the request body and call $form->submit(). That does the same thing as $form->handleRequest(), which you're probably familiar with. So after this function is called, the form processing has happened. After it, add an if statement with the normal if (!$form->isValid()):

145 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 15
class ProgrammerController extends BaseController
{
... lines 18 - 21
public function newAction(Request $request)
{
... lines 24 - 25
$this->processForm($request, $form);
if (!$form->isValid()) {
... lines 29 - 30
}
... lines 32 - 46
}
... lines 48 - 143
}

If we find ourselves here, it means we have validation errors. Let's see if this is working. Use the dump() function and the $form->getErrors() function, passing it true and false as arguments. That'll give us all the errors in a big tree. Cast this to a string - getErrors() returns a FormErrorIterator object, which has a nice __toString() method. Add a die at the end:

145 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 21
public function newAction(Request $request)
{
... lines 24 - 27
if (!$form->isValid()) {
... line 29
dump((string) $form->getErrors(true, false));die;
}
... lines 32 - 46
}
... lines 48 - 145

Let's run our test to see what this looks like. Copy the testValidationErrors method name, then run:

php bin/phpunit -c app --filter testValidationErrors

Ok, there's our printed dump. Woh, that is ugly. That's the nice HTML formatting that comes from the dump() function. But it's unreadable here. I'll show you a trick to clean that up.

It's dumping HTML because it detects that something is accessing it via the web interface. But we kinda want it to print nicely for the terminal. Above the dump() function, add header('Content-Type: cli'):

145 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 27
if (!$form->isValid()) {
header('Content-Type: cli');
dump((string) $form->getErrors(true, false));die;
}
... lines 32 - 145

That's a hack - but try the test now:

bin/phpunit -c app --filter testValidationErrors

Ok, that's a sweet looking dump. We've got the validation error for the nickname field and another for a missing CSRF token - we'll fix that soon. But, validation is working.

Collecting the Validation Errors

So now we just need to collect those errors and put them into a JSON response. To help with that, I'm going to paste a new private function into the bottom of ProgrammerController:

170 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 10
use Symfony\Component\Form\FormInterface;
... lines 12 - 15
class ProgrammerController extends BaseController
{
... lines 18 - 151
private function getErrorsFromForm(FormInterface $form)
{
$errors = array();
foreach ($form->getErrors() as $error) {
$errors[] = $error->getMessage();
}
foreach ($form->all() as $childForm) {
if ($childForm instanceof FormInterface) {
if ($childErrors = $this->getErrorsFromForm($childForm)) {
$errors[$childForm->getName()] = $childErrors;
}
}
}
return $errors;
}
}

If you're coding with me, you'll find this in a code block on this page - copy it from there. Actually, I adapted this from some code in FOSRestBundle.

A Form object is a collection of other Form objects - one for each field. And sometimes, fields have sub-fields, which are yet another level of Form objects. It's a tree. And when validation runs, it attaches the errors to the Form object of the right field. That's the treky, I mean techy, explanation of this function: it recursively loops through that tree, fetching the errors off of each field to create an associative array of those errors.

Head back to newAction() and use this: $errors = $this->getErrorsFromForm() and pass it the $form object. Now, create a $data array that will eventually be our JSON response.

170 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 21
public function newAction(Request $request)
{
... lines 24 - 27
if (!$form->isValid()) {
$errors = $this->getErrorsFromForm($form);
$data = [
... lines 32 - 34
];
... lines 36 - 37
}
... lines 39 - 53
}
... lines 55 - 170

Remember, we want type, title and errors keys. Add a type key: this is the machine name of what went wrong. How about validation_error - I'm making that up. For title - we'll have the human-readable version of what went wrong. Let's use: "There was a validation error". And for errors pass it the $errors array.

Finish it off! Return a new JsonResponse() with $data and the 400 status code:

170 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 21
public function newAction(Request $request)
{
... lines 24 - 27
if (!$form->isValid()) {
$errors = $this->getErrorsFromForm($form);
$data = [
'type' => 'validation_error',
'title' => 'There was a validation error',
'errors' => $errors
];
return new JsonResponse($data, 400);
}
... lines 39 - 53
}
... lines 55 - 170

Phew! Let's give it a try:

bin/phpunit -c app --filter testValidationErrors

Oof! That's not passing! That's a huge error. The dumped response looks perfect. The error started on ProgrammerControllerTest where we use assertResponsePropertiesExist(). Whoops! And there's the problem - I had assertResponsePropertyExists() - what you use to check for a single field. Make sure your's says assertResponsePropertiesExist().

Try it again:

bin/phpunit -c app --filter testValidationErrors

It's passing! Let's pretend I made that mistake on purpose - it was nice because we could see that the dumped response looks exactly like we wanted.

Leave a comment!

  • 2017-08-16 Nina

    Hello, please, I need help
    When I write in the cmd:
    > php bin/phpunit -c app/ --filter testValidationErrors

    I've got this instead of result testValidationErrors()
    What does it mean? And how I can to fix this?

    dir=$(d=${0%[/\\]*}; cd "$d"; cd "../vendor/phpunit/phpunit" && pwd)

    # See if we are running in Cygwin by checking for cygpath program
    if command -v 'cygpath' >/dev/null 2>&1; then
    # Cygwin paths start with /cygdrive/ which will break windows PHP,
    # so we need to translate the dir path to windows format. However
    # we could be using cygwin PHP which does not require this, so we
    # test if the path to PHP starts with /cygdrive/ rather than /usr/bin
    if [[ $(which php) == /cygdrive/* ]]; then
    dir=$(cygpath -m "$dir");
    fi
    fi

    dir=$(echo $dir | sed 's/ /\ /g')
    "${dir}/phpunit" "$@"

  • 2017-06-06 Sławek Grochowski

    I have found a little bug. But here is the fix:

    $jsonData = $request->getContent();

    $arrayData = json_decode($jsonData, true);

    $arrayForm = $form->all();

    $data = array_diff_key($arrayData, $arrayForm);

    $errors = $this->getErrorsFromForm($form);

    if($data){
    array_push($errors, ['extra_fields' => $data]);
    }

    $apiProblem = new ApiProblem(
    400,
    ApiProblem::TYPE_VALIDATION_ERROR
    );
    $apiProblem->set('errors', $errors);

    throw new ApiProblemException($apiProblem);

  • 2017-05-16 Mike Ritter

    "Let's just pretend I made that mistake on purpose".

    Riiiight

  • 2017-04-28 Diego Aguiar

    Thanks for sharing your solution, I bet someone will find it useful ;)

    Cheers!

  • 2017-04-28 Sławek Grochowski

    I have written a function to get information about "extra fields" in form. Maybe it will be usefull for others. I think it's must have for every API on Symfony form.
    public function throwValidationErrorsResponse(FormInterface $form, Request $request)
    {
    $jsonData = $request->getContent();

    $arrayData = json_decode($jsonData, true);

    $data = array_flip($arrayData);

    $children = $form->all();

    $arr = [];

    foreach ($children as $ch) {
    $arr []= $ch->getName();
    }

    $data = array_diff($data, $arr);

    $errors = $this->getErrorsFromForm($form);

    array_push($errors, ['extra_fields' => $data]);

    $apiProblem = new ApiProblem(
    400,
    ApiProblem::TYPE_VALIDATION_ERROR
    );
    $apiProblem->set('errors', $errors);

    throw new ApiProblemException($apiProblem);
    }

  • 2017-04-24 Diego Aguiar

    Ohh I got you now!

    Look's like Symfony doesn't do it by default, in that case you would have to validate every form field by hand and add the desired error message to it

    Cheers!

  • 2017-04-23 Sławek Grochowski

    Understand. But I think about diffrent case. Like a have a form with field "name". When I send a POST with JSON {"naem": "value} - so I misspell, "naem" -> should be "name". I get information "This form should not contain extra fields." But better info would be something like this -> "naem field do not exist".

  • 2017-04-21 Diego Aguiar

    Hey Slawek!

    Symfony's Form Component doesn't allow you to have more fields than the ones you have specified in your form type, but you can configure that behaviour by using the "option resolver"


    //your FormType class
    public function configureOptions(OptionsResolver $resolver)
    {
    $resolver->setDefaults([
    'allow_extra_fields' => true
    ]);
    }

    Have a nice day!

  • 2017-04-21 Sławek Grochowski

    Can we get more information about this error "This form should not contain extra fields." ? Mostly I misspell name of the field in json. And this always takes too much time to figure it out which field.

  • 2017-04-20 weaverryan

    Hey!

    Ah, lame! Hmm, it looks like that error usually comes when you're trying to decode a date or do something (like multiplication) with a non-number... but I can't think of where we would be sing that!

    So, let's do some debugging! First, do you always get this error no matter what values you fill in for your fields? Second, can you use the tricks on this page - https://knpuniversity.com/s... - to see if you can see a stacktrace for the error? I'm sure if we can see a stracktrace (if you find it, you could take a screenshot or copy-paste), I'm sure we'll be able to hunt down the cause!

    Cheers!

  • 2017-04-20 lupogeorge

    There was an Error!!!!

    Notice: A non well formed numeric value encountered

  • 2017-04-20 lupogeorge

    Hi , I get this error and I cant figure out what is wrong

  • 2017-04-11 Victor Bocharsky

    Hi Robert,

    Good catch! We have to add a note about it, but I think it would be done in the Symfony Forms tutorial.

    Thank you for this notice!

    Cheers!

  • 2017-04-10 Robert

    Hello,

    Validating unsubmitted form (only $form->isValid()) is now deprecated and will be removed in 4.0. You should use $form->isSubmitted && $form->isValid();

  • 2017-03-23 weaverryan

    Cheers buddy! And good luck! :)

  • 2017-03-23 Chan Sheeran

    Hi, Ryan,

    Thanks for your reply!

    Yes, I modified the codes from your tutorials to fit my project, they works like a charm now. Thanks for all these efforts, I learnt a lot from you. I will carry on to buy more after I finish these big tricky part, very helpful and interesting, thanks again to your team and you for these brilliant works!

    Cheers!

    Sheeran

  • 2017-03-22 weaverryan

    Yo Chan Sheeran!

    Sorry for my late reply, but even better you figured it out for yourself. You're 100% correct on (1) - that's part of the reason we have some "tricks" in our base test class to help print the response in the terminal - it's not always something you can see just by going to the browser!

    About (2), by default, Guzzle throws an exception whenever the response is a 400 or 500 status code. However, in ApiTestCase, we add some configuration that says to NOT do this, as it makes testing a bit more difficult. I see you're using Guzzle 6 - check out the code download for episode 4 of this series (where the code has been upgraded to Guzzle 6) - you'll see the http_errors => false line that configures our client this way). Did you possibly change the ApiTestCase code to use new Guzzle, but without this option? It's really fine either way - you can let Guzzle throw exceptions or turn them off - obviously, it just changes how your code will look :).

    And welcome to REST - it's tricky, but powerful!

    Cheers!

  • 2017-03-21 Chan Sheeran

    SOVLED!
    1. The GET request from browser won't get the $data array, that's why my browser returns more fields than a POST request from Guzzle;
    2. I used the


    try{/*your requests*/}
    catch (ClientException $e){
    $response = $e->getResponse();
    //verify assertions here, although it's a bit odd as some assertor() functions doesn't work properly
    }


    I suspect it's the guzzle version issue, it threw 400 error (as it should) before assertions codes were reached so I failed my test.
    I am a RESTful newbie, if it's not right, please let me know, cheers! :)

    Best regards,
    Sheeran

  • 2017-03-21 Chan Sheeran

    Hi, Ryan,

    May I ask you a question, please?

    I haven't used the code from this tutorial, but my own project codes, I have done the similar setting as this tutorial, the $response come back with right 400 statusCode and right response body. However, my test cannot be passed and the assertion part haven't been reached (namely, there are some errors before I could do the assertions). I then tried to use try{} catch(){} to catch the $exception body, the return content are the same as :
    {
    "type": "validation_error",
    "title": "There was a validation error",
    "errors": {
    "birthday": [
    "Please input your birthday."
    ]
    }
    }
    But the test just never pass.

    This is the error details below:
    Failure! when making the following request:
    POST: http://localhost:8000/api/register

    HTTP/1.1 400 Bad Request
    Host: localhost:8000
    Connection: close
    X-Powered-By: PHP/7.0.14
    Cache-Control: no-cache
    Content-Type: application/json
    Date: Tue, 21 Mar 2017 00:09:54 GMT
    {
    "type": "validation_error",
    "title": "There was a validation error",
    "errors": {
    "birthday": [
    "Please input your birthday."
    ]
    }
    }
    E 1 / 1 (100%)

    Time: 1.06 seconds, Memory: 8.00MB

    There was 1 error:

    1) MyProject\UserBundle\Tests\Controller\Api\RegistrationControllerTest::testValidationErrors
    GuzzleHttp\Exception\ClientException: Client error response [url] http://localhost:8000/api/register [status code] 400 [reason phrase] Bad Request

    My project used the FOSUserBundle, and when I tried to use the Browser to see what $response return, it appears as below:
    {"type":"validation_error","title":"There was a validation error","errors":{"email":["Please enter an email."],"username":["Please enter a username."],"plainPassword":{"first":["Please enter a password."]},"birthday":["Please input your birthday."]}}

    In the $data array, I DO input 'email'/'username' and 'plainPassword'. I spent lots of time on google to find a solution for my case, but I haven't find anything helpful.

    Do you have any idea about this? Any help will be appreciated!!!

    Cheers!
    Sheeran