Normalizer Decoration & "Normalizer Aware"
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOur mission is clear: set up our normalizer to decorate Symfony's core normalizer service so that we can add the owner:read group when necessary and then call the decorated normalizer.
Setting up for Decoration
And we know decoration! Add public function __construct() with private NormalizerInterface $normalizer:
| // ... lines 1 - 4 | |
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface | |
| { | |
| public function __construct(private NormalizerInterface $normalizer) | |
| { | |
| } | |
| // ... lines 12 - 23 | |
| } |
Below in normalize(), add a dump() then return $this->normalizer->normalize() passing $object $format, and $context. For supportsNormalization(), do the same thing: call supportsNormalization() on the decorated class and pass the args:
| // ... lines 1 - 6 | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface | |
| { | |
| // ... lines 9 - 12 | |
| public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null | |
| { | |
| dump('IT WORKS!'); | |
| return $this->normalizer->normalize($object, $format, $context); | |
| } | |
| public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool | |
| { | |
| return $this->normalizer->supportsNormalization($data, $format); | |
| } | |
| } |
To complete decoration, head to the top of the class. I'll remove a few old use statements... then say #[AsDecorator] passing serializer, which I mentioned is the service id for the top-level main normalizer:
| // ... lines 1 - 4 | |
| use Symfony\Component\DependencyInjection\Attribute\AsDecorator; | |
| // ... lines 6 - 7 | |
| ('serializer') | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface | |
| { | |
| // ... lines 11 - 25 | |
| } |
Ok! We haven't made any changes yet... so we should still see the one failing test. Try it:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
Woh! An explosion! Wow.
ValidationExceptionListener::__construct(): Argument #1 ($serializer) must be of typeSerializerInterface,AddOwnerGroupsNormalizergiven.
Okay? When we add #[AsDecorator('serializer')], it means that our service replaces the service known as serializer. So, everyone that's depending on the serializer service will now be passed us... and then the original serializer is passed to our constructor.
So, what's the problem? Decoration has worked several times before. The problem is that the serializer service in Symfony is... kind of big. It implements NormalizerInterface, but also DenormalizerInterface, EncoderInterface, DecoderInterface and SerializerInterface! But our object only implements one of these . And so, when our class is passed to something that expects an object with one of those other 4 interfaces, it explodes.
If we truly wanted to decorate the serializer service, we would need to implement all five of those interfaces... which is just a ugly and too much. And that's fine!
Decorating a Lower-Level Normalizer
Instead of decorating the top level normalizer, let's decorate one specific normalizer: the one that's responsible for normalizing ApiResource objects into JSON-LD. This is another spot where you can rely on the documentation to give you the exact service ID you need. It's api_platform.jsonld.normalizer.item:
| // ... lines 1 - 4 | |
| use Symfony\Component\DependencyInjection\Attribute\AsDecorator; | |
| // ... lines 6 - 7 | |
| ('api_platform.jsonld.normalizer.item') | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface | |
| { | |
| // ... lines 11 - 25 | |
| } |
Try the test again: testOwnerCanSeeIsPublishedField
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
Yes! We see our dump! And... a 400 error? Let me pop open the response so we can see it. Strange:
The injected serializer must be an instance of
NormalizerInterface.
And it's coming from deep inside of API Platform's serializer code. So... decorating normalizers is not a very friendly process. It's well-documented, but weird. When you decorate this specific normalizer, you also need to implement SerializerAwareInterface. And that's going to require you to have a setSerializer() method. Oh, let me import that use statement: I don't know why that didn't come automatically:
| // ... lines 1 - 6 | |
| use Symfony\Component\Serializer\SerializerAwareInterface; | |
| // ... lines 8 - 10 | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface | |
| { | |
| // ... lines 13 - 28 | |
| public function setSerializer(SerializerInterface $serializer) | |
| { | |
| // ... lines 31 - 33 | |
| } | |
| } |
There we go.
Inside, say, if $this->normalizer is an instanceof SerializerAwareInterface, then call $this->normalizer->setSerializer($serializer):
| // ... lines 1 - 10 | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface | |
| { | |
| // ... lines 13 - 28 | |
| public function setSerializer(SerializerInterface $serializer) | |
| { | |
| if ($this->normalizer instanceof SerializerAwareInterface) { | |
| $this->normalizer->setSerializer($serializer); | |
| } | |
| } | |
| } |
I don't even want to get into the details of this: it just happens that the normalizer we're decorating implements another interface... so we need to also implement it.
Let's try this again.
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
Finally, we have the dump and it's failing the assertion we expect... since we haven't added the group yet. Let's do that!
Adding the Dynamic Group
Remember the goal: if we own this DragonTreasure, we want to add the owner:read group. On the constructor, autowire the Security service as a property:
| // ... lines 1 - 5 | |
| use Symfony\Bundle\SecurityBundle\Security; | |
| // ... lines 7 - 12 | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface | |
| { | |
| public function __construct(private NormalizerInterface $normalizer, private Security $security) | |
| { | |
| } | |
| // ... lines 18 - 38 | |
| } |
Then, down here, if $object is an instanceof DragonTreasure - because this method will be called for all of our API resource classes - and $this->security->getUser() equals $object->getOwner(), then call $context['groups'][] to add owner:read:
| // ... lines 1 - 4 | |
| use App\Entity\DragonTreasure; | |
| // ... lines 6 - 12 | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface | |
| { | |
| // ... lines 15 - 18 | |
| public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null | |
| { | |
| if ($object instanceof DragonTreasure && $this->security->getUser() === $object->getOwner()) { | |
| $context['groups'][] = 'owner:read'; | |
| } | |
| // ... lines 24 - 25 | |
| } | |
| // ... lines 27 - 38 | |
| } |
Phew! Try that test one more time:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
We got it! We can now return different fields on an object-by-object basis.
Also Decorating the Denormalizer
If you want to also add owner:write during denormalization, you would need to implement a second interface. I'm not going to do the whole thing... but you would implement DenormalizerInterface, add the two methods needed, call the decorated service... and change the argument to be a union type of NormalizerInterface and DenormalizerInterface.
Finally, the service that you're decorating for denormalization is different: it's api_platform.serializer.normalizer.item. However, if you want to decorate both the normalizer and denormalizer in the same class, you'd need to remove #[AsDecorator] and move the decoration config to services.yaml... because a single service can't decorate two things at once. API Platform covers that in their docs.
Ok, I'm going to undo all of that... and just stick with adding owner:read. Next: now that we have a custom normalizer, we can easily do wacky things like adding a totally custom field to our API that doesn't exist in our class.
8 Comments
Hey there :)
Could you briefly explain the difference between our two approaches to add dynamic groups?
For the admin:read and admin:write, we decorated the context builder. Got the security, checked out, if user is admin and added the groups.
For the owner:read, we now decorated the NormalizerInterface and same here: Got the security, checked if user === owner and added the groups.
My question is:
Could we also add the adminGroups in the AddOwnerGroupsNormalizer (of course it needs a new name then)? (I think we could?)
Could we also add the owner:read group in the context builder? (We can, but then it's not DragonTreasure related?)
Theoretically, the contextbuilder is triggered before the normalizer, because if "is responsible for building the normalization or denormalization contexts that are then passed into the serializer"
And there our custom Normalizer is used used.
So to sum this up:
When is it necessary to add a dynamic group in the ContextBuilder, and when is it necessary to add it in the normalization process?
Because we can't refer to the ApiEntity/Object in the ContextBuilder?
Hello,
I'm trying to make a denormalizer but I don't fit in it while I fit well in
if ($this->denormalizer instanceof DenormalizerInterface) {Thanks for your help
Hey @aratinau
I believe you're decorating the wrong service, this is the service you should decorate
api_platform.jsonld.normalizer.itemCheers!
I doubt if this is the way to go.
From the Symfony docs to create a custom Normalizer (https://symfony.com/doc/6.4/serializer/custom_normalizer.html#creating-a-new-normalizer):
So even the maker-bundle (Which I might not have updated to the latest version since I used the starting project from this tutorial) has a
make:serializer:normalizerthat isn't 100% correct for Symfony these days.use NormalizerAwareTrait;in the top of the class. This is now already enough to have a copy of the 'normal' normalizer available as$this->normalizer.normalize(),supportsNormalization()and also the newergetSupportedTypes()).supportsNormalization(). We want to work on data, if data is a DragonTreasure. So a simplereturn $data instanceof DragonTreasureseems like it will be enough. It's not, but we'll get back to that.getSupportedTypes(). We must answer what we support and if it's cacheable or not. Now, it seems that we can say we always support $data of type DragonTreasure. Again, this is not true :), but we'll get back to that. For now, let's say it is:return [ DragonTreasure::class => true ];normalize()method. Just like decorating a service, we call the normal normalizer we received from the NormalizerAwareTrait. So we start with a simplereturn $this->normalizer->normalize($object, $format, $context);.If you try this... you'll notice your application / tests will really crash. Yes, crash. A segfault, or you will see 'it is killed'.
Why?
The serializer checks if we support a DragonTreaure. We do, so our
normalize()method gets called. But in that method, we call the normal normalizer to do the business. What does it do? It will check for available normalizers. It will find 'us', we say 'yes, we support that DragonTreasure object', and ournormalize()method gets called. And again, and again and again, and boom.What we need to do, is somehow make sure we only do our normalizer only once per object.
This is pretty simple. In the first line of
normalize(), we adjust the$contextand set something in it, that will tell us that we already handled it. We can set all kind of things in the$context, but a little convention of myself is to set the array key of my class-string to true. So, above the return statement innormalize()add a line:Now, in
supportsNormalization(), we need to say we only support this object if we didn't handle it before.So, change the return line to:
The last problem we have now, is that in
getSupportedTypes()we told that oursupportsNormalization()method is cacheable. But since we now have a check for the$context, it's not. So changegetSupportedTypes()to be:Now, we have a normalizer that isn't a decorated service, gets only called for DragonTreasure objects and only gets called once.
Ready to do te job we were here for. Under the
$context[self::class] = true;line innormalize(), we're going to check if the current user is the owner for this DragonTreasure. If it is, we add 'owner:read' to the$context['groups']. After that, we're going to call the 'normal' normalizer, to do its thing like it normally would. Since we now have the proper groups set, the proper properties will show up in the object.I'll summarize my final normalizer, including strict typing for phpstan:
If someone knows a way to keep the supportsNormalization() call cacheable, it would be nicer. Maybe just accept it and make it cacheable, and in the normalize() call just return $data as is, if we handled the object before? I'm just thinking out loud here, no clue if this will work. The above code does, and also seems easier to extend to a denormalizer as well.
I'm running into the following error trying to create a denormalizer. I can't seem to figure out the cause.
Circular reference detected for service "api_platform.serializer.normalizer.validation_exception", path: "api_platform.serializer.normalizer.validation_exception -> Ap <br /> p\Normalizer\AddOwnerGroupsDenormalizer -> debug.serializer -> debug.serializer.inner -> api_platform.serializer.normalizer.validation_exception".Any idea as to what might be happening?
Here's my code:
Hey @Dhd
It seems like you're decorating the main Normalizer service, so when Symfony autowire your class
AddOwnerGroupsDenormalizerit's injecting another instance of that same class, hence the circular reference. Try specifying what Normalizer you want in your constructor, you can use the "inner" service like thisYou can read more about decorating services in Symfony here: https://symfony.com/doc/current/service_container/service_decoration.html
Cheers!
<?php
The error I'm now getting is
ApiPlatform\Symfony\Validator\Serializer\ValidationExceptionNormalizer::__construct(): Argument #1 ($decorated) must be of type Symfony\Component\Serializer\Normalizer\NormalizerInterface, App\Normalizer\CompanyOwnerDeNormalizer given, called in /var/www/api/var/cache/dev/ContainerZ3bWSUF/App_KernelDevDebugContainer.php on line 1828Right, you're decorating the normalizer service so your class requires to implement the Normalizer interface
"Houston: no signs of life"
Start the conversation!