DTO Validation & Security
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 SubscribeLet's talk about validation! When we ->post()
to our endpoint, the internal object will be our UserApi
object... which means that's what will be validated. Watch. Send no fields to the POST
request... and run that test:
symfony php bin/phpunit --filter=testPostToCreateUser
Oh uh: 500 error! And... I bet you can guess why. It says:
User::setEmail()
: Argument #1 (
Coming from our state processor on line 59. Because there are no validation constraints at all on UserApi
, the email
property remains null
. Then, over here on line 59, we try to transfer that null email
onto our entity. It doesn't like that, there's a short fist fight, and we see this error. And even if it did accept a null value, it would eventually fail in the database because the email isn't allowed to be null there.
We're missing validation. Fortunately, it's easy to add... once you know that validation will happen on the UserApi
object, not the entity.
Configuration the Operations
But before we run wild and add constraints, let's specify the operations
... so we only have the ones we need: new Get()
, new GetCollection()
, new Post()
... we'll add some config to that in a moment... as well as new Patch()
and new Delete()
.
// ... lines 1 - 9 | |
use ApiPlatform\Metadata\Delete; | |
use ApiPlatform\Metadata\Get; | |
use ApiPlatform\Metadata\GetCollection; | |
use ApiPlatform\Metadata\Patch; | |
use ApiPlatform\Metadata\Post; | |
// ... lines 15 - 20 | |
( | |
// ... line 22 | |
operations: [ | |
new Get(), | |
new GetCollection(), | |
new Post( | |
// ... line 27 | |
), | |
new Patch(), | |
new Delete(), | |
], | |
// ... lines 32 - 35 | |
) | |
// ... lines 37 - 39 | |
class UserApi | |
{ | |
// ... lines 42 - 66 | |
} |
Back when our User
entity was the #[ApiResource]
, the Post()
operation had an extra validationContext
option with groups
set to Default
and postValidation
.
// ... lines 1 - 9 | |
use ApiPlatform\Metadata\Delete; | |
use ApiPlatform\Metadata\Get; | |
use ApiPlatform\Metadata\GetCollection; | |
use ApiPlatform\Metadata\Patch; | |
use ApiPlatform\Metadata\Post; | |
// ... lines 15 - 20 | |
( | |
// ... line 22 | |
operations: [ | |
new Get(), | |
new GetCollection(), | |
new Post( | |
validationContext: ['groups' => ['Default', 'postValidation']], | |
), | |
new Patch(), | |
new Delete(), | |
], | |
// ... lines 32 - 35 | |
) | |
// ... lines 37 - 39 | |
class UserApi | |
{ | |
// ... lines 42 - 66 | |
} |
Thanks to that, when the Post()
operation happened, it would run all the normal validators plus any that were in this postValidation
group. We'll see why we need that in a moment.
Adding the Constraints
Ok, constraint time! $id
isn't even writable... we want $email
to be #[NotBlank]
... and be an #[Email]
.
// ... lines 1 - 18 | |
use Symfony\Component\Validator\Constraints as Assert; | |
// ... lines 20 - 39 | |
class UserApi | |
{ | |
// ... lines 42 - 44 | |
public ?string $email = null; | |
// ... lines 48 - 66 | |
} |
We want $username
to be #[NotBlank]
...
// ... lines 1 - 18 | |
use Symfony\Component\Validator\Constraints as Assert; | |
// ... lines 20 - 39 | |
class UserApi | |
{ | |
// ... lines 42 - 44 | |
public ?string $email = null; | |
public ?string $username = null; | |
// ... lines 51 - 66 | |
} |
then $password
is an interesting one. $password
should be allowed to be blank if we're doing a PATCH
request to edit it... but required on a POST
request. To accomplish that, add #[NotBlank]
but with a groups
option set to postValidation
.
// ... lines 1 - 18 | |
use Symfony\Component\Validator\Constraints as Assert; | |
// ... lines 20 - 39 | |
class UserApi | |
{ | |
// ... lines 42 - 44 | |
public ?string $email = null; | |
public ?string $username = null; | |
// ... lines 51 - 55 | |
groups: ['postValidation']) | (|
public ?string $password = null; | |
// ... lines 58 - 66 | |
} |
This constraint will only be run when we're validating the postValidation
group... which means it will only be run for the Post()
operation.
Okay, that should do it! Run the test now:
symfony php bin/phpunit --filter=testPostToCreateUser
And... a beautiful 422 status code!
UniqueEntity constraint?
By the way, one of the other validation constraints we had before on the User
entity was #[UniqueEntity]
. That prevented someone from creating two users with the same email
or username
. I don't have that on UserApi
, but we should. The #[UniqueEntity]
constraint, unfortunately, only works on entities... so we'd need to create a custom validator to have that on UserApi
. We're not going to worry about that right, but I wanted to point it out.
Anyway, back over on the test, re-add the fields. Validation, check!
Adding Security
The next thing we need to re-add - code that used to live on User
- is security. Up here on the API level, for the entire resource, require is_granted("ROLE_USER")
.
// ... lines 1 - 20 | |
( | |
// ... lines 22 - 35 | |
security: 'is_granted("ROLE_USER")', | |
// ... lines 37 - 39 | |
) | |
// ... lines 41 - 43 | |
class UserApi | |
{ | |
// ... lines 46 - 70 | |
} |
This means that we need to be logged in to use any of the operations for this resource... by default. Then we overrode that. In Post()
, we definitely can't be logged in yet because we're registering our user. Say, security
set to is_granted("PUBLIC_ACCESS")
which is a special attribute that will always pass.
// ... lines 1 - 20 | |
( | |
// ... line 22 | |
operations: [ | |
// ... lines 24 - 25 | |
new Post( | |
security: 'is_granted("PUBLIC_ACCESS")', | |
// ... line 28 | |
), | |
// ... lines 30 - 33 | |
], | |
// ... line 35 | |
security: 'is_granted("ROLE_USER")', | |
// ... lines 37 - 39 | |
) | |
// ... lines 41 - 43 | |
class UserApi | |
{ | |
// ... lines 46 - 70 | |
} |
Down here for Patch()
, we had security('is_granted("ROLE_USER_EDIT")')
.
// ... lines 1 - 20 | |
( | |
// ... line 22 | |
operations: [ | |
// ... lines 24 - 25 | |
new Post( | |
security: 'is_granted("PUBLIC_ACCESS")', | |
// ... line 28 | |
), | |
new Patch( | |
security: 'is_granted("ROLE_USER_EDIT")' | |
), | |
// ... line 33 | |
], | |
// ... line 35 | |
security: 'is_granted("ROLE_USER")', | |
// ... lines 37 - 39 | |
) | |
// ... lines 41 - 43 | |
class UserApi | |
{ | |
// ... lines 46 - 70 | |
} |
In our app, we decided that you need to have this special tole to be able to edit users.
Ok! Let's run all the tests for User
:
symfony php bin/phpunit tests/Functional/UserResourceTest.php
And... oh. Not bad! Three out of four! The failure comes from testTreasuresCannotBeStolen()
. That doesn't sound good!
If we check that out... this is a interesting test: we ->patch()
to update a $user
, and then try to set the dragonTreasures
property to a treasure that is owned by a different user. You can see that this $dragonTreasure
is owned by $otherUser
... but we're currently updating $user
.
What we're attempting to do is steal this $dragonTreasure
from $otherUser
and make it part of $user
. Dragons do not appreciate being robbed, so we're asserting that this is a 422 status code... because previously, we had a custom validator that prevented this.
Well, it still exists - it's this TreasuresAllowedOwnerChangeValidator
- but it's not being applied to UserApi
... and it needs to be updated to work with it. We'll do this later.
More importantly right now, the dragonTreasures
property isn't even writable! In UserApi
, above $dragonTreasures
, we have writable: false
. In a bit, we're going to change that so that we can write dragonTreasures
again. And when we do, we'll bring back that validator and make sure this test passes.
Next: If you look at the processor or the provider we created, these classes are pretty generic. They could almost work for UserApi
and a future DragonTreasureApi
class... and any other DTO class we create that's tied to an entity. The only part that's specific to User
is the code that maps to and from the User
entity and the UserApi
class.
If we could handle that mapping... in some system that lives outside our provider and processor... we could reuse them. Let's make this a reality next!
Hi,
I wonder if you can give us a hand on this matter :)
We are trying to validate that a GET api call comes with at least one of two defined filter fields with valida data, but don't know how to accomplish this and what's the best way.
Let me give you and example, imagine we have a user entity with 2 filter fields: id and email. We need only one of them with a non empty right value, and we need a validation in a GET request before making a custom filter.
Thanks a lot!