Custom Normalizer: Object-by-Object Dynamic 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

We now know how to add dynamic groups: we added admin:read above phoneNumber and then, via our context builder, we're dynamically adding that group to the serialization context if the authenticated user has ROLE_ADMIN.

So... we're pretty cool! We can easily run around and expose input our output fields only to admin users by using these two groups.

But... the context builder - and also the more advanced resource metadata factory - has a tragic flaw! We can only change the context globally. What I mean is, we're deciding which groups should be used for normalizing or denormalizing a specific class... no matter how many different objects we might be working with. It does not allow us to change the groups on an object-by-object basis.

Let me give you a concrete example: in addition to making the $phoneNumber readable by admin users, I now want a user to also be able to read their own phoneNumber: if I make a request and the response will contain data for my own User object, it should include the phoneNumber field.

You might think:

Ok, let's put phoneNumber in some new group, like owner:read... and add that group dynamically in the context builder.

That's great thinking! But... look in the context builder, look at what's passed to the createFromRequest() method... or really, what's not passed: it does not pass us the specific object that's being serialized. Nope, this method is called just once per request.

Creating a Normalizer

Ok, no worries. Context builders are a great way to add or remove groups on a global or class-by-class basis. But they are not the way to dynamically add or remove groups on an object-by-object basis. Nope, for that we need a custom normalizer. Let's convince MakerBundle to create one for us. Run:

php bin/console make:serializer:normalizer

Call this UserNormalizer. When an object is being transformed into JSON, XML or any format, it goes through two steps. First, a "normalizer" transforms the object into an array. And second, an "encoder" transforms that array into whatever format you want - like JSON or XML.

When a User object is serialized, it's already going through a core normalizer that looks at our normalization groups & reads the data via the getter methods. We're now going to hook into that process so that we can change the normalization groups before that core normalizer does its job.

Go check out the new class: src/Serializer/Normalizer/UserNormalizer.php.

... lines 1 - 8
class UserNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
{
private $normalizer;
public function __construct(ObjectNormalizer $normalizer)
{
$this->normalizer = $normalizer;
}
public function normalize($object, $format = null, array $context = array()): array
{
$data = $this->normalizer->normalize($object, $format, $context);
// Here: add, edit, or delete some data
return $data;
}
public function supportsNormalization($data, $format = null): bool
{
return $data instanceof \App\Entity\BlogPost;
}
public function hasCacheableSupportsMethod(): bool
{
return true;
}
}

This works a bit differently than the context builder - it works more like the voter system. The serializer doesn't have just one normalizer, it has many normalizers. Each time it needs to normalize something, it loops over all the normalizers, calls supportsNormalization() and passes us the data that it needs to normalize. If we return true from supportsNormalization(), it means that we know how to normalize this data. And so, the serializer will call our normalize() method. Our normalizer is then the only normalizer that will be called for this data: we are 100% responsible for transforming the object into an array.

Normalizer Logic

Of course... we don't really want to completely take over the normalization process. What we really want to do is change the normalization groups... and then call the core normalizer so it can do its normal work. That's why the class was generated with a constructor where we're autowiring a class called ObjectNormalizer. This is the main, core, normalizer for objects: it's the one that's responsible for reading the data via our getter methods. So... cool! Our custom normalizer is basically... just offloading all the work to the core normalizer!

Let's start customizing this! For supportsNormalization(), return $data instanceof User. So if the thing that's being normalized is a User object, we handle that.

... lines 1 - 9
class UserNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
{
... lines 12 - 34
public function supportsNormalization($data, $format = null): bool
{
return $data instanceof User;
}
... lines 39 - 48
}

Now we know that normalize() will only be called if $object is a User. Let's add some PHPDoc above this to help my editor.

... lines 1 - 18
/**
* @param User $object
*/
public function normalize($object, $format = null, array $context = array()): array
... lines 23 - 50

The goal here is to check to see if the User object that's being normalized is the same as the currently-authenticated User. If it is, we'll add that owner:read group. Add that check on top: if $this->userIsOwner($object) - that's a method we'll create in a minute - then, add the group. The $context is passed as the third argument... and we're passing it to the core normalizer below. Let's modify it first! Use $context['groups'][] = 'owner:read.

That's lovely! A normalizer is only used for... um... normalizing an object to an array - it is not used for denormalizing an array back into an object. That's why we're always adding owner:read here. If you wanted to create this same feature for denormalization... and add an owner:write group.. you'll need to create a separate denormalizer class. There's no MakerBundle command to generate it, but the logic will be almost identical to this... and you can even make your one normalizer class implement both NormalizerInterface and DenormalizerInterface.

Oh, also, we don't need to check for the existence of a groups key on the array because, in our system, we are always setting at least one group.

Let's add that missing method: private function userIsOwner(). This will take a User object and return a bool. For now, fake it: return rand(0, 10) > 5.

... lines 1 - 35
private function userIsOwner(User $user): bool
{
return mt_rand(0, 10) > 5;
}
... lines 40 - 46

And... I think that's it! Like with voters, this is a situation where we don't need to add any configuration: as soon as we create a class and make it implement NormalizerInterface, the serializer will see it and start using it.

So... let's take this for a test drive! Back on the docs, I'm currently not logged in. Let's refresh the page... and create a new user. How about email goudadude@example.com, password foo, same username, no cheeseListings, but with a phoneNumber. Execute and... perfect! A 201 status code. Copy that email... go back to the homepage.. and log in: goudadude@example.com, password foo and... go!

Cool! Now that we're authenticated, head back to /api. Yep, the web debug toolbar confirms that I'm a "gouda dude". Let's try the GET operation to fetch a collection of users. Because of our random logic, I'd expect some results to show the phoneNumber and some not. Execute and... hey! The first user has a phoneNumber field! It's null... because apparently we didn't set a phoneNumber for that user, but the field is there. And, thanks to the randomness, there is no phoneNumber for the second and third users. If you try the operation again... yes! This time the first and second users have that field, but not the third. Hey! We're now dynamically adding the owner:read group on an object-by-object basis! Normalizers rock!

But... wait a second. Something is wrong! We're missing the JSON-LD fields for these users. Well, ok, we have them on the top-level for the collection itself... and even the embedded CheeseListing data has them... but each user is missing @id and @type. Something in our new normalizer killed the JSON-LD stuff!

Next, let's figure out what's going on, find this bug, then crush it!

Leave a comment!

  • 2020-07-19 weaverryan

    Hey Hans Grinwis!

    Ah! I didn't realize that! That $context holds a lot of treasures and... unfortunately, because it's just an array - I'm not aware of everything that's in there. So yes, my guess is that your way as an easier way to grab this. I'll make a note of it in the gist!

    Thanks!

  • 2020-07-18 Hans Grinwis

    Hello weaverryan ,

    Thanks for this course and the owner:write gist. However, I didn't try your approach to getting the object via
    $apiResource = $this->requestStack->getCurrentRequest()->attributes->get('data');

    The object is already present in the context:

    if (
    !isset($context['object_to_populate']) ||
    $context['object_to_populate']->getOwner() === $this->security->getUser()
    ) {
    $context['groups'][] = 'owner:write';
    }

  • 2020-04-28 weaverryan

    Hey Hannah !

    Ah, duh! I should have checked the context of your message! And sorry for my slow reply again!

    Based on your error, it means that this value is null:


    $apiResource = $this->requestStack->getCurrentRequest()
    ->attributes->get('data');

    For a POST (create) that actually WILL be null: the "data" key is set by a class called ReadListener when it queries for the object that you are "editing". So, I think my gist is actually mistaken: we need to check if that is null. If it is, then this is a "CREATE" and we should probably *always* add the owner:write group because... whoever is creating this, IS the owner. I've just updated the gist - https://gist.github.com/wea... - let me know if that works!

    Cheers!

  • 2020-04-23 Hannah

    Hi weaverryan,
    sorry for the confusion.
    I am talking about the version from your last comment, where you implemented owner:write.

    https://gist.github.com/wea...

    And 1000 thanks for this course!

  • 2020-04-23 weaverryan

    Hi Hannah!

    Sorry for my slow response! Hmm that's really interesting! Am I correct to assume that the method that's calling userIsOwner is the normalize method? Or is it something else? I'm just not sure what "stage" of the tutorial your UserNormalizer is at - I don't think we get to a line 104 in our tutorial (but I could be looking at the wrong spot!).

    If normalize() is calling this method with a "null" value for the $object argument (which is what we're passing to $this->userIsOwner(), then yes, something is very wrong :). The normalize method is called when we're taking an object and turning it *into* an array. And normalize() should only be called if supportsNormalization() returns true... and THAT method is checking to make sure that we're dealing with a User object.

    Because that doesn't make a lot of senes, my guess is that I'm making some incorrect assumptions about how your UserNormalizer looks. Could you post the code here so I can see what the context around what is calling userIsOwner()?

    Cheers!

  • 2020-04-19 Hannah

    Hey weaverryan ,
    when using the latest revision of UserNormalizer, it is not possible to create an User anymore.


    "hydra:description": "Argument 1 passed to App\\Serializer\\Normalizer\\UserNormalizer::userIsOwner() must be an instance of App\\Entity\\User, null given, called in /Users/hannah/PhpstormProjects/api-platform-security/src/Serializer/Normalizer/UserNormalizer.php on line 104"

    UserResourceTest::testCreateUser also fails with the same Error.

    I'm confused :)

  • 2019-11-18 weaverryan

    Hey Christian!

    Sorry for my slow reply! It's SymfonyCon week. I've just (thanks to your report) realized my mistake, and it's a tricky one that, to be honest, hadn't occurred to me before. The problem is (and you'll see if it you look in the denormalizer code I sent above) the object is deserialized and THEN the group is added. At that point, it's too late - the deserialization has already happened. Adding the group then has (duh Ryan!) no effect.

    So, adding the owner:write is much tricker than I suspected. What we *need* to know (even before deserializing) is whether the User/entity that ApiPlatform queried from the database (the one whose "id" is in the URL) is owned by the current user. We can fetch the "current API resource data" out of the Request object. I've updated the gist to reflect this. The last revision will show you the changes - https://gist.github.com/wea... - and this time I fully verified that the code works on the project :).

    Let me know if it works for you!

    Cheers!

  • 2019-11-13 Christian

    Hey weaverryan,

    ok -> Updating phoneNumber as expected:
    @Groups({"admin:read", "owner:read", "user:write"})

    nok -> Status Code 200 but not updating value of phoneNumber:
    @Groups({"admin:read", "owner:read", "owner:write"})

  • 2019-11-13 weaverryan

    Hey Christian!

    Hmm. What do the groups look like above the phoneNumnber property? Is owner:write one of the groups above it?

    Cheers!

  • 2019-11-12 Christian

    Thanks a lot weaverryan ,

    i used your example and tried to update the phoneNumber field as owner with owner:write set.
    I get Statuscode 200, but the field is not updating.
    When changing to user:write everything is working as expected and the field is updating.

    Any Ideas?

  • 2019-11-12 weaverryan

    Hiya Christian !

    Absolutely :). It's basically the same process, but you *do* need to implement a few interfaces, and instead of working with objects and turning them into something else, you're working with data and a "type" - so the whole process is literally backwards :).

    Here's a finished example to try: https://gist.github.com/wea... - I just hacked this together, so please let me know if something doesn't work. The most interesting part might be the "diff" of how I transformed the finished normalizer from this project and added the denormalizer stuff - https://gist.github.com/wea...

    Let me know if it helps! Cheers!

  • 2019-11-07 Christian

    Could you please provide the owner:write code?

  • 2019-09-17 weaverryan

    Ah... that *is* crazy! I realize that would affect the auto-added group names (e.g. user:read), but I'm not sure why it would affect the dynamic group name - owner:read. Anyways, I'm glad you got it sorted!

  • 2019-09-17 weaverryan

    Bah! Nevermind - I just saw your comment above :p

  • 2019-09-17 weaverryan

    Hey Ramazan !

    Hmm, so we know that there is a problem with the serialization process... it appears to not be respecting the group that you're adding to context and that you've correctly put above the phoneNumber property (I can't spot any subtle typos or other things). I would try this:

    A) Change userIsOwner() to just *always* return true. You probably already did this while debugging - but I just want to make sure the randomness isn't confusing things.
    B) Change owner:read above the $phoneNumber property to user:read. Does the property show up? I'm just trying to see if the "normal" situation works... i.e. is there something fundamentally wrong with this property?

    Let me know what you find out - thanks for the excellent details in your comment.

    Cheers!

  • 2019-09-16 Ramazan

    Crazy situation and few hours later all the time on this problem, now it works.
    Everything started to work when I switched other entities api-platform shortName definition by
    entityName to entityname (all lower case and without underscore)

  • 2019-09-16 Ramazan

    And there is something else, might be related to my problem.
    I'm working within docker containers with nginx.

    In AutoGroupResourceMetadataFactory if I update:
    sprintf('%s:%s', $shortName, $readOrWrite),
    with
    sprintf('%s:%sFOOOOOOO', $shortName, $readOrWrite),
    I won't see the difference in the documentation "Models", even if I do cache:clear it won't change, but if I restart my nginx container it will show the update.

    There is my definition in services.yml:

    App\ApiPlatform\AutoGroupResourceMetadataFactory:
    decorates: 'api_platform.metadata.resource.metadata_factory'
    arguments: [ '@App\ApiPlatform\AutoGroupResourceMetadataFactory.inner' ]
    decoration_priority: -20

  • 2019-09-16 Ramazan

    Hello weaverryan ,

    When I put the debug point after $this->normalizer->normalize() line I can see that $data doesn't contain a phoneNumber field, but it is present in the $object properties.

    My UserNormalizer class:
    https://gist.github.com/rak...

    My Entity:
    https://gist.github.com/rak...

    My AutoGroupResourceMetadataFactory:
    https://gist.github.com/rak...

  • 2019-09-16 weaverryan

    Hey Ramazan!

    Hmmm. I don't think the vendors version should make a difference - this functionality is pretty "core" to Symfony's serializer. Can you post your custom normalizer and also your entity class? It might be some small detail that's causing the problem. You could also add a debug right *after* the $this->normalizer->normalize() line to see if the $data contains the phoneNumber field or not (my guess is that it does *not* because you don't see it in the API response, but it's something to check).

    Cheers!

  • 2019-09-16 Ramazan

    Hello,

    I'm confused because this owner:user thing doesn't work with me.
    I don't see any phoneUser at all, when I enable xdebug, I see that's it's going into this line:
    $context['groups'][] = 'owner:read';
    But there is no any phoneNumber in the GET /users endpoint
    maybe because I don't use the same composer.lock?
    https://gist.github.com/rak...