Buy

EntityType Validation: Restrict Invalid programmerId

In the test, the Programmer is owned by weaverryan and then we authenticate as weaverryan. So, we're starting a battle using a Programmer that we own. Time to mess that up. Create a new user called someone_else:

70 lines tests/AppBundle/Controller/Api/BattleControllerTest.php
... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 44
public function testPOSTBattleValidationErrors()
{
// create a Programmer owned by someone else
$this->createUser('someone_else');
... lines 49 - 67
}
}

There still is a user called weaverryan:

70 lines tests/AppBundle/Controller/Api/BattleControllerTest.php
... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
protected function setUp()
{
parent::setUp();
$this->createUser('weaverryan');
}
... lines 15 - 68
}

But now, change the programmer to be owned by this sketchy someone_else character:

70 lines tests/AppBundle/Controller/Api/BattleControllerTest.php
... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 44
public function testPOSTBattleValidationErrors()
{
// create a Programmer owned by someone else
$this->createUser('someone_else');
$programmer = $this->createProgrammer([
'nickname' => 'Fred'
], 'someone_else');
... lines 52 - 67
}
}

With this setup, weaverryan will be starting a battle with someone_else's programmer. This should cause a validation error: this is an invalid programmerId to pass.

Form Field Sanity Validation

But how do we do that? Is there some annotation we can use for this? Nope! This validation logic will live in the form. "What!?" you say - "Validation always goes in the class!". Not true! Every field type has a little bit of built-in validation logic. For example, the NumberType will fail if a mischievous - or confused - user types in a word. And the EntityType will fail if someone passes an id that's not found in the database. I call this sanity validation: the form fields at least make sure that a sane value is passed to your object.

If we could restrict the valid programmer id's to only those owned by our user, we'd be in business.

But first, add the test: assertResponsePropertyEquals() that errors.programmerId[0] should equal some dummy message:

70 lines tests/AppBundle/Controller/Api/BattleControllerTest.php
... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 44
public function testPOSTBattleValidationErrors()
{
... lines 47 - 66
$this->asserter()->assertResponsePropertyEquals($response, 'errors.programmerId[0]', '???');
}
}

Run the test to see the failure:

./vendor/bin/phpunit --filter testPostBattleValidationErrors

Yep: there's no error for programmerId yet.

Let's fix that. Right now, the client can pass any valid programmer id, and the EntityType happily accepts it. To shrink that to a smaller list, we'll pass it a custom query to use.

Passing the User to the Form

To do that, the form needs to know who is authenticated. In BattleController, guarantee that first: add $this->denyAccessUnlessGranted('ROLE_USER'):

41 lines src/AppBundle/Controller/Api/BattleController.php
... lines 1 - 11
class BattleController extends BaseController
{
/**
* @Route("/api/battles")
* @Method("POST")
*/
public function newAction(Request $request)
{
$this->denyAccessUnlessGranted('ROLE_USER');
... lines 21 - 38
}
}

To pass the user to the form, add a third argument to createForm(), which is a little-known options array. Invent a new option: user set to $this->getUser():

41 lines src/AppBundle/Controller/Api/BattleController.php
... lines 1 - 11
class BattleController extends BaseController
{
... lines 14 - 17
public function newAction(Request $request)
{
... lines 20 - 22
$form = $this->createForm(BattleType::class, $battleModel, [
'user' => $this->getUser()
]);
... lines 26 - 38
}
}

This isn't a core Symfony thing: we're creating a new option.

To allow this, open BattleType and find configureOptions. Here, you need to say that user is an allowed option. One way is via $resolver->setRequired('user'):

42 lines src/AppBundle/Form/BattleType.php
... lines 1 - 11
class BattleType extends AbstractType
{
... lines 14 - 32
public function configureOptions(OptionsResolver $resolver)
{
... lines 35 - 38
$resolver->setRequired(['user']);
}
}

This means that whoever uses this form is allowed to, and in fact must, pass a user option.

With that, you can access the user object in buildForm(): $user = $options['user']:

42 lines src/AppBundle/Form/BattleType.php
... lines 1 - 11
class BattleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$user = $options['user'];
... lines 17 - 30
}
... lines 32 - 40
}

None of this is unique to API's: we're just giving our form more power!

Passing the query_builder Option

Let's filter the programmer query: add a query_builder option set to an anonymous function with ProgrammerRepository as the only argument. Add a use for $user so we can access it:

42 lines src/AppBundle/Form/BattleType.php
... lines 1 - 4
use AppBundle\Repository\ProgrammerRepository;
... lines 6 - 11
class BattleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 16 - 17
$builder
->add('programmerId', EntityType::class, [
... lines 20 - 21
'query_builder' => function(ProgrammerRepository $repo) use ($user) {
... line 23
},
])
... lines 26 - 29
;
}
... lines 32 - 40
}

We could write the query right here, but you guys know I don't like that: keep your queries in the repository! Call a new method createQueryBuilderForUser() and pass it $user:

42 lines src/AppBundle/Form/BattleType.php
... lines 1 - 11
class BattleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 16 - 17
$builder
->add('programmerId', EntityType::class, [
... lines 20 - 21
'query_builder' => function(ProgrammerRepository $repo) use ($user) {
return $repo->createQueryBuilderForUser($user);
},
])
... lines 26 - 29
;
}
... lines 32 - 40
}

Copy that method name and shortcut-your way to that class by holding command and clicking ProgrammerRepository. Add public function createQueryBuilderForUser() with the User $user argument:

48 lines src/AppBundle/Repository/ProgrammerRepository.php
... lines 1 - 8
class ProgrammerRepository extends EntityRepository
{
... lines 11 - 19
public function createQueryBuilderForUser(User $user)
{
... lines 22 - 24
}
... lines 26 - 46
}

Inside, return $this->createQueryBuilder() and alias the class to programmer. Then, just andWhere('programmer.user = :user') with ->setParameter('user', $user):

48 lines src/AppBundle/Repository/ProgrammerRepository.php
... lines 1 - 5
use AppBundle\Entity\User;
... lines 7 - 8
class ProgrammerRepository extends EntityRepository
{
... lines 11 - 19
public function createQueryBuilderForUser(User $user)
{
return $this->createQueryBuilder('programmer')
->andWhere('programmer.user = :user')
->setParameter('user', $user);
}
... lines 26 - 46
}

Done! The controller passes the User to the form, and the form calls the repository to create the custom query builder. Now, if someone passes a programmer id that we do not own, the EntityType will automatically cause a validation error. Security is built-in.

Head back to the terminal to try it!

./vendor/bin/phpunit --filter testPostBattleValidationErrors

Awesome! Well, it failed - but look! It's just because we don't have the real message yet: it returned This value is not valid.. That's the standard message if any field fails the "sanity" validation.

Tip

You can customize this message via the invalid_message form field option.

Copy that string and paste it into the test:

70 lines tests/AppBundle/Controller/Api/BattleControllerTest.php
... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 44
public function testPOSTBattleValidationErrors()
{
... lines 47 - 66
$this->asserter()->assertResponsePropertyEquals($response, 'errors.programmerId[0]', 'This value is not valid.');
}
}

Run it!

./vendor/bin/phpunit --filter testPostBattleValidationErrors

So that's "sanity" validation: it's form fields watching your back to make sure mean users don't start sending crazy things to us. And it happens automatically.

Leave a comment!

  • 2016-06-13 weaverryan

    Hi Vlad!

    You're exactly right - the query builder determines if there is a validation error. If the user submits the id "5", and that is *not* found via the query builder (i.e. there IS a user id 5, but it is not one returned by the query builder), then the validation error is thrown.

    I talk a bit more about this "sanity validation" here: http://knpuniversity.com/scree...

    Cheers!

  • 2016-06-13 Vlad

    Does the result of the custom query builder determine if there is a validation error? If not, then what?