Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Normalizer & Completely Custom Fields

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Our 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!

Options for Adding Custom Fields

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.

Adding Proper Security

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.

Fixing the Tests

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.

Adding the Custom isMe Field

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.

Leave a comment!

9
Login or Register to join the conversation
Patrick D. Avatar
Patrick D. Avatar Patrick D. | posted 2 years ago

When will you be doing a tutorial on DTOs?

1 Reply

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!

1 Reply
Zaharedine R. Avatar
Zaharedine R. Avatar Zaharedine R. | posted 10 months ago

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..

Reply

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!

Reply

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.

Reply

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!

Reply

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 ?

Reply

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!

Reply

Hello Ryan,

Thank for your answer. I fix the type problem with your test proposition. All tests pass.

Cheers.

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// 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
    }
}