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 SubscribeNow that the plainPassword
property is a legitimate part of our API, let's add some validation... because you can't create a new user without a password! Add Assert\NotBlank
:
... lines 1 - 67 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 70 - 94 | |
private ?string $plainPassword = null; | |
... lines 97 - 293 | |
} |
Piece of cake! Well, that just created a new problem... but let's blindly move forward and pretend that everything is fine.
Copy the first test and paste to create a second method that will make sure we can update users. Call it testPatchToUpdateUser()
. This one is simple: make a new user - $user = UserFactory::createOne()
, add actingAs($user)
then ->patch()
to /api/users/
then $user->getId()
to edit ourselves.
For the json
, just send username
, add assertStatus(200)
.... then we don't need any of this other stuff:
... lines 1 - 7 | |
class UserResourceTest extends ApiTestCase | |
{ | |
... lines 10 - 32 | |
public function testPatchToUpdateUser(): void | |
{ | |
$user = UserFactory::createOne(); | |
$this->browser() | |
->actingAs($user) | |
->patch('/api/users/' . $user->getId(), [ | |
'json' => [ | |
'username' => 'changed', | |
], | |
]) | |
->assertStatus(200); | |
} | |
} |
As a reminder, up on the Patch
operation for User
... here it is, we're requiring that the user has ROLE_USER_EDIT
. Because we're logging in as a "full" user, we should have that... and everything should work fine... famous last words.
Run:
symfony php bin/phpunit --filter=testPatchToUpdateUser
And... oh! 200 expected, got 415. That's a new one! Click to open the last response... then I'll View Source to make it more clear. Interesting:
The content-Type:
application/json
is not supported. Supported MIME types areapplication/merge-patch+json
.
Let's unpack this. We're making a PATCH
request... and PATCH
requests are quite simple: we send a subset of fields, and only those fields are updated.
Whelp, it turns out that the PATCH
HTTP method can get a whole heck of a lot more interesting than this. In the greater interwebs, there are competing formats for how the data should look when using a PATCH request and each format means something different.
Currently, API Platform supports only one of these formats: application/merge-patch+json
. This format is... kind of what you expect. It says: if you send a single field, only that single field will be changed. But it also has other rules, like how you could set email
to null
... and that would actually remove the email
field. That doesn't really make sense in our API, but the point is: the format defines rules about how your JSON should look for a PATCH
request and what that means. If you want to know more, there's a document that describes everything: it's quite short and readable.
So, API platform only supports one format for PATCH requests at the moment. But, in the future, they might support more. And so, when you make a PATCH
request, API Platform requires you to send a Content-Type
header set to application/merge-patch+json
... so that you're explicitly telling API platform which format your JSON is using.
In other words, to fix our error, pass a headers
key with Content-Type
set to application/merge-patch+json
:
... lines 1 - 7 | |
class UserResourceTest extends ApiTestCase | |
{ | |
... lines 10 - 32 | |
public function testPatchToUpdateUser(): void | |
{ | |
... lines 35 - 36 | |
$this->browser() | |
... line 38 | |
->patch('/api/users/' . $user->getId(), [ | |
... lines 40 - 42 | |
'headers' => ['Content-Type' => 'application/merge-patch+json'] | |
]) | |
... line 45 | |
} | |
} |
Try this now:
symfony php bin/phpunit --filter=testPatchToUpdateUser
It still fails, but now it's a validation error! The takeaway is simple: PATCH requests require this Content-Type
header.
But wait! We did a bunch of PATCH
requests over in DragonTreasureResourceTest
and those worked fine without the header! What the what?
That... was kind of on accident. Inside DragonTreasure
, in the first tutorial... here it is, we added a formats
key so that we could add CSV support:
... lines 1 - 28 | |
( | |
... lines 30 - 49 | |
formats: [ | |
'jsonld', | |
'json', | |
'html', | |
'jsonhal', | |
'csv' => 'text/csv', | |
], | |
... lines 57 - 66 | |
) | |
... lines 68 - 252 |
It turns out that, for some complex internal reasons, by adding formats
, we removed the requirement for needing that header. So we were "getting away" with not setting the header in DragonTreasureResourceTest
... even though we should be setting it. It may have been better to set formats
on the GetCollection
operation only... since that's the only spot we need CSV.
Anyway, that's why we didn't need it before, but we do need it now. By the way, if adding this header every time you call ->patch
is annoying, this is another situation where you could add a custom method to browser - like ->apiPatch()
- which would work the same, but add that header automatically.
Ok, back to the test! It's failing with a 422. Open the error response. Ah, it's from plainPassword
: this field should not be blank!
The plainPassword
property is not persisted to the database. So, it's always empty at the start of an API request. When we create a User
, we absolutely do want this field to be required. But when we're editing a User
, we don't need this field to be set. They can set it in order to change their password, but that's optional.
This is the first spot where we need conditional validation: validation should happen on one operation, but not on others. The way to fix this is with validation groups, which is very similar to serialization groups.
Find the Post
operation and pass a new option called validationContext
with, you guessed it, groups
! Set this to an array with a group called Default
with a capital D. Then invent a second group: postValidation
:
... lines 1 - 26 | |
( | |
... line 28 | |
operations: [ | |
... lines 30 - 31 | |
new Post( | |
... line 33 | |
validationContext: ['groups' => ['Default', 'postValidation']], | |
), | |
... lines 36 - 42 | |
], | |
... lines 44 - 49 | |
) | |
... lines 51 - 296 |
When the validator validates an object, by default, it validates everything that's in a group called Default
. And any time you have a constraint, by default that constraint is in that Default
group. So what we're saying here is:
We want to validate all the normal constraints plus any constraints that are in the
postValidation
group.
Now we can take that postValidation
, go down to plainPassword
and set groups
to postValidation
:
... lines 1 - 68 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 71 - 95 | |
groups: ['postValidation']) ( | |
private ?string $plainPassword = null; | |
... lines 98 - 294 | |
} |
That removes this constraint from the Default
group and only includes it in the postValidation
group. Thanks to this, other operations like Patch
will not run this, but the Post
operation will.
Run the test now:
symfony php bin/phpunit --filter=testPatchToUpdateUser
We're unstoppable! In fact, all of our tests are passing!
But head's up! In User
, we still have both Put
and Patch
. I haven't played with it much yet, but the new Put
behavior, in theory, does support creating objects. This can make things tricky: do we need to require the password or not? It depends! This might be another reason for removing the Put
operation to keep life simple. That gives us one operation for creating and one operation for editing.
Next: let's explore making our serialization groups dynamic based on the user. This will give us another way to include or not include fields based on who is logged in. And it'll lead us towards adding super custom fields.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}