EntityType Validation: Restrict Invalid programmerId
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeIn 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
:
// ... 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
:
// ... 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:
// ... 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:
// ... 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')
:
// ... 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()
:
// ... 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')
:
// ... 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']
:
// ... 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:
// ... 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
:
// ... 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:
// ... 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)
:
// ... 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:
// ... 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.
Hello,
I usually do this check for entity direct from the controller, this is not a good practice? For example, in cases where a user can not start a battle with the ID 10 scheduler but can start with the ID 11 scheduler, I would need to do a check for this, how would I do this using the FormType query_builder?