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 SubscribeOur UserNormalizer
is now totally set up. These classes are beautiful & flexible: we can add custom normalization groups on an object-by-object basis. They're also weird: you need to know about this NormalizerAwareInterface
thing... and you need to understand the idea of setting a flag into the $context
to avoid calling yourself recursively. But once you've got that set up, you're gold!
And if you look more closely... we're even more dangerous than you might realize. The job of a normalizer is to turn an object - our User
object - into an array of data and return it. You can tweak which data is included by adding more groups to the $context
... but you could also add custom fields... right here!
Well, hold on a minute. Whenever possible, if you need to add a custom field, you should do it the "correct" way. In CheeseListing
, when we wanted to add a custom field called shortDescription
, we did that by adding a getShortDescription()
method and putting it in the cheese:read
group. Boom! Custom field!
Why is this the correct way of doing it? Because this causes the field to be seen & documented correctly.
But, there are two downsides - or maybe limitations - to this "correct" way of doing things. First, if you have many custom fields... it starts to get ugly: you might have a bunch of custom getter and setter methods just to support your API. And second, if you need a service to generate the data for the custom field, then you can't use this approach. Right now, I want to add a custom isMe
field to User
. We couldn't, for example, add a new isMe()
method to User
that returns true or false based on whether this User
matches the currently-authenticated user... because we need a service to know who is logged in!
So... since we can't add an isMe
field the "correct" way... how can we add it? There are two answers. First, the... sort of... "second" correct way is to use a DTO class. That's something we'll talk about in a future tutorial. It takes more work, but it would result in your custom fields being documented properly. Or second, you can hack the field into your response via a normalizer. That's what we'll do now.
Oh, but before we get there, I almost forgot that we need to make this userIsOwner()
method... actually work! Add a constructor to the top of this class and autowire the Security
service. I'll hit Alt -> Enter and go to "Initialize Fields" to create that property and set it. Down in the method, say $authenticatedUser = $this->security->getUser()
with some PHPDoc above this to tell my editor that this will be a User
object or null if the user is not logged in. Then, if !$authenticatedUser
, return false. Otherwise, return $authenticatedUser->getEmail() === $user->getEmail()
. We could also compare the objects themselves.
... lines 1 - 11 | |
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface | |
{ | |
... lines 14 - 17 | |
private $security; | |
... line 19 | |
public function __construct(Security $security) | |
{ | |
$this->security = $security; | |
} | |
... lines 24 - 52 | |
private function userIsOwner(User $user): bool | |
{ | |
/** @var User|null $authenticatedUser */ | |
$authenticatedUser = $this->security->getUser(); | |
if (!$authenticatedUser) { | |
return false; | |
} | |
return $authenticatedUser->getEmail() === $user->getEmail(); | |
} | |
... lines 64 - 68 | |
} |
Let's try this: if we fetch the collection of all users, the phoneNumber
field should only be included in our user record. And... no phoneNumber
, no phoneNumber
and... yes! The phoneNumber
shows up only on the third record: the user that we're logged in as.
Oh, but this does break one of our tests. Run all of them:
php bin/phpunit
Most of these will pass, but... we do get one failure:
Failed asserting that an array does not have the key
phoneNumber
on UserResourceTest.php line 66.
Let's open that test and see what's going on. Ah yes: this is the test where we check to make sure that if you set a phoneNumber
on a User
and make a GET request for that User
, you do not get the phoneNumber
field back unless you're logged in as an admin.
But we've now changed that: in addition to admin users, an authenticated user will also see their own phoneNumber
. Because we're logging in as cheeseplease@example.com
... and then fetching that same user's data, it is returning the phoneNumber
field. That's the correct behavior.
To fix the test, change createUserAndLogin()
to just createUser()
... and remove the first argument. Now use $this->createUserAndLogin()
to log in as a totally different user. Now we're making a GET request for the cheeseplease@example.com
user data but we're authenticated as this other user. So, we should not see the phoneNumber
field.
... lines 1 - 8 | |
class UserResourceTest extends CustomApiTestCase | |
{ | |
... lines 11 - 50 | |
public function testGetUser() | |
{ | |
... line 53 | |
$user = $this->createUser('cheeseplease@example.com', 'foo'); | |
$this->createUserAndLogIn($client, 'authenticated@example.com', 'foo'); | |
... lines 56 - 78 | |
} | |
} |
Run the tests again:
php bin/phpunit
And... all green.
Ok, back to our original mission... which will be delightfully simple: adding a custom isMe
field to User
. Because $data
is an array, we can add whatever fields we want. Up here, I'll create a variable called $isOwner
set to what we have in the if statement: $this->userIsOwner($object)
. Now we can use $isOwner
in the if
and add the custom field: $data['isMe'] = $isOwner
.
... lines 1 - 11 | |
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface | |
{ | |
... lines 14 - 27 | |
public function normalize($object, $format = null, array $context = array()): array | |
{ | |
$isOwner = $this->userIsOwner($object); | |
if ($isOwner) { | |
$context['groups'][] = 'owner:read'; | |
} | |
... lines 34 - 38 | |
$data['isMe'] = $isOwner; | |
... lines 40 - 41 | |
} | |
... lines 43 - 69 | |
} |
Et voilà! Test it! Execute the operation again and... there it is: isMe
false, false and true! Just remember the downside to this approach: our documentation has no idea that this isMe
field exists. If we refresh this page and open the docs for fetching a single User
... yep! There's no mention of isMe
. Of course, you could add a public function isMe()
in User
, put it in the user:read
group, always return false
, then override the isMe
key in your normalizer with the real value. That would give you the custom field and the docs. But sheesh... that's... getting kinda hacky.
Next, let's look more at the owner
field on CheeseListing
. It's interesting: we're currently allowing the user to set this property when they POST to create a User
. Does that make sense? Or should it be set automatically? And if we do want an API user to be able to send the owner
field via the JSON, how do we prevent them from creating a CheeseListing
and setting the owner
to some other user? It's time to see where security & validation meet.
Hey Patrick D.!
I'm not sure - we have several tutorials in front of it. So while it *is* tentatively on the schedule, it's likely will *not* be released earlier than 6 weeks from now. Sorry I can't give you better news!
Cheers!
Hi,
Are you planning to add a react-native course to see how to log in from react native app with session ? That's my main purpose so i'm a little bit disappointed because i didn't manage to do it. It's seems like people are not using this method but JWT instead..
Hey Zarloon,
Thank you for your interest in SymfonyCasts tutorials! Unfortunately, no plans to cover this topic fairly speaking, but I'll add it to our idea pool! JWT is more complex, but also a more powerful. If you're looking for something simpler - year, probably session-based thing would be easier. Well, maybe I can give you some tips about it and maybe they help you :) So, if we're talking about non-SPA page - it's easy, you just need to set true or false value on a property base on whether there's a current logged-in user or no. You can do it in twig template for example, if there's a "app.user" - set authorized to true, otherwise - set to false. And then, in your JS you just read that property and execute specific logic according to its value. But if you're talking about SPA - it's a bit more complex, not sure about the best approach there, probably JWT is something you need.
Btw, we cover JWT in a few screencasts, you can leverage our search to find related courses/videos: https://symfonycasts.com/se... - I hope that would help you.
Cheers!
Hey Team,
I am using PHP8 and SF 5.3.9 ApiPlatform 2.6
When I run the tests, I get an error message:
App\Tests\Functional\UserResourceTest::testUpdateUser
Failed asserting that an array has the subset Array &0 (
'username' => 'zanoni'
).
--- Expected
+++ Actual
@@ @@
'@id' => '/api/users/149',
'@type' => 'User',
'phoneNumber' => NULL,
- 'username' => 'zanoni',
)
You have an idea ?
I noticed that when I launch a request on get/api/users, I have all the informations of each user except those of the connected user
I have only this :
{
"@id": "/api/users/13",
"@type": "User",
"phoneNumber": "0009998887",
"isMe": true
}
Cheers.
Hey Stephane!
Hmmm. I'm not sure what the problem is, however, you may have given me a hint:
I noticed that when I launch a request on get/api/users, I have all the informations of each user except those of the connected user
You mentioned "I have all the informations of each user". Do you really see ALL of the fields for those other users? If so, it makes me think that you, by default, are serializing the user with NO serialization groups. And so, it's showing all of the fields. Then, when you are the "connected user", you are then adding a single group - owner:read
. So suddenly, you are only serializing things in that ONE group.
So I would back up and make sure that, in your normal situation, you are serializing using the user:read
group.
Let me know if that helps!
Cheers!
Hey @Ryan,
Thank for your answer.
Yes I see all fields (no phone) for other users ?
I think I find the problem : I add [] on line $context['groups'][] = 'owner:read'; in normalize method of UserNormalizer class.
But now when I run tests, there is an error :
1) App\Tests\Functional\UserResourceTest::testGetUser
Error: [] operator not supported for strings
probably a type error of a property in the User class ?
Hey Stephane!
Hmm. Yes, that was a good fix. Now, about the error:
Error: [] operator not supported for strings
It tells me that $context['groups']
is set to a string currently instead of an array. Technically speaking, the serializer works fine if you set the serialization groups to a string - it just turns that into an array that contains that one group internally. But the question in this case is: why is it a string? In my User
class, I have:
normalizationContext={"groups"={"user:read"}},
So, groups is set to an array with user:read
inside. But in your case, it's a string. So you have 2 options:
1) Try to figure out why your groups are a string and not an array and fix it to be an array.
2) Code around this in UserNormalizer:
// if it's a string, turn it into an array
if (is_string($context['groups'])) {
$context['groups'] = [$context['groups']];
}
$context['groups'][] = 'owner:read';
Let me know if that helps!
Cheers!
Hello Ryan,
Thank for your answer. I fix the type problem with your test proposition. All tests pass.
Cheers.
// composer.json
{
"require": {
"php": "^7.1.3, <8.0",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^2.1", // v2.4.5
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^1.6", // 1.11.2
"doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
"doctrine/orm": "^2.4.5", // v2.7.2
"nelmio/cors-bundle": "^1.5", // 1.5.6
"nesbot/carbon": "^2.17", // 2.21.3
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
"symfony/asset": "4.3.*", // v4.3.2
"symfony/console": "4.3.*", // v4.3.2
"symfony/dotenv": "4.3.*", // v4.3.2
"symfony/expression-language": "4.3.*", // v4.3.2
"symfony/flex": "^1.1", // v1.18.7
"symfony/framework-bundle": "4.3.*", // v4.3.2
"symfony/http-client": "4.3.*", // v4.3.3
"symfony/monolog-bundle": "^3.4", // v3.4.0
"symfony/security-bundle": "4.3.*", // v4.3.2
"symfony/twig-bundle": "4.3.*", // v4.3.2
"symfony/validator": "4.3.*", // v4.3.2
"symfony/webpack-encore-bundle": "^1.6", // v1.6.2
"symfony/yaml": "4.3.*" // v4.3.2
},
"require-dev": {
"hautelook/alice-bundle": "^2.5", // 2.7.3
"symfony/browser-kit": "4.3.*", // v4.3.3
"symfony/css-selector": "4.3.*", // v4.3.3
"symfony/maker-bundle": "^1.11", // v1.12.0
"symfony/phpunit-bridge": "^4.3", // v4.3.3
"symfony/stopwatch": "4.3.*", // v4.3.2
"symfony/web-profiler-bundle": "4.3.*" // v4.3.2
}
}
When will you be doing a tutorial on DTOs?