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!

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.21.3
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.9.6
        "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/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // v2.5.1
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}