Decoración del normalizador y "Normalizer Aware" (consciente del normalizador)
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 SubscribeNuestra misión es clara: configurar nuestro normalizador para que decore el servicio normalizador del núcleo de Symfony, de modo que podamos añadir el grupo owner:read cuando sea necesario y, a continuación, llamar al normalizador decorado.
Configuración para la decoración
¡Y ya conocemos la decoración! Añade public function __construct() conprivate NormalizerInterface $normalizer:
| // ... lines 1 - 4 | |
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface | |
| { | |
| public function __construct(private NormalizerInterface $normalizer) | |
| { | |
| } | |
| // ... lines 12 - 23 | |
| } |
Abajo en normalize(), añade un dump() luego return $this->normalizer->normalize()pasando $object $format , y $context. Para supportsNormalization(), haz lo mismo: llama a supportsNormalization() en la clase decorada y pasa los 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); | |
| } | |
| } |
Para completar la decoración, dirígete a la parte superior de la clase. Quitaré unas cuantas declaraciones antiguas use... y luego diré #[AsDecorator] pasando serializer, que ya he mencionado que es el id de servicio para el normalizador principal de nivel superior:
| // ... lines 1 - 4 | |
| use Symfony\Component\DependencyInjection\Attribute\AsDecorator; | |
| // ... lines 6 - 7 | |
| ('serializer') | |
| class AddOwnerGroupsNormalizer implements NormalizerInterface | |
| { | |
| // ... lines 11 - 25 | |
| } |
¡Vale! Aún no hemos hecho ningún cambio... así que deberíamos seguir viendo la única prueba que falla. Pruébalo:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
¡Woh! ¡Una explosión! Guau.
ValidationExceptionListener::__construct(): El argumento nº 1 ($serializer) debe ser de tipoSerializerInterface,AddOwnerGroupsNormalizerdado.
¿De acuerdo? Cuando añadimos #[AsDecorator('serializer')], significa que nuestro servicio sustituye al servicio conocido como serializer. Así, todos los que dependen del servicio serializer pasarán ahora a nosotros... y luego elserializer original se pasa a nuestro constructor.
Entonces, ¿cuál es el problema? La decoración ya ha funcionado varias veces. El problema es que el servicio serializer de Symfony es... un poco grande. ImplementaNormalizerInterface, ¡pero también DenormalizerInterface, EncoderInterface,DecoderInterface y SerializerInterface! Pero nuestro objeto sólo implementa uno de ellos. Y así, cuando nuestra clase se pasa a algo que espera un objeto con una de esas otras 4 interfaces, explota.
Si de verdad quisiéramos decorar el servicio serializer, tendríamos que implementar las cinco interfaces... lo cual es feo y demasiado. ¡Y no pasa nada!
Decorar un normalizador de nivel inferior
En lugar de decorar el nivel superior normalizer, vamos a decorar un normalizador concreto: el que se encarga de normalizar los objetos ApiResource enJSON-LD. Éste es otro punto en el que puedes confiar en la documentación para que te dé el ID de servicio exacto que necesitas. Es 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 | |
| } |
Vuelve a hacer la prueba: testOwnerCanSeeIsPublishedField
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
¡Sí! ¡Vemos nuestro volcado! Y... ¿un error 400? Déjame abrir la respuesta para que podamos verla. Extraño:
El serializador inyectado debe ser una instancia de
NormalizerInterface.
Y procede de lo más profundo del código del serializador de API Platform. Así que... decorar normalizadores no es un proceso muy amigable. Está bien documentado, pero es raro. Cuando decoras este normalizador específico, también tienes que implementarSerializerAwareInterface. Y eso va a requerir que tengas un método setSerializer(). Oh, déjame importar esa declaración use: No sé por qué no ha aparecido automáticamente:
| // ... 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 | |
| } | |
| } |
Ya está.
Dentro, digamos, si $this->normalizer es un instanceof SerializerAwareInterface, entonces llama a $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); | |
| } | |
| } | |
| } |
Ni siquiera quiero entrar en los detalles de esto: lo que ocurre es que el normalizador que estamos decorando implementa otra interfaz... así que también tenemos que implementarla.
Intentémoslo de nuevo.
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
Por último, tenemos el volcado y falla la aserción que esperamos... puesto que aún no hemos añadido el grupo. ¡Hagámoslo!
Añadir el grupo dinámico
Recuerda el objetivo: si poseemos este DragonTreasure, queremos añadir el grupo owner:read. En el constructor, autocablea el servicio Security como una propiedad:
| // ... 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 | |
| } |
Entonces, aquí abajo, si $object es un instanceof DragonTreasure -porque este método se llamará para todas nuestras clases de recursos API- y $this->security->getUser()es igual a $object->getOwner(), entonces llama a $context['groups'][] para añadirowner: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 | |
| } |
¡Uf! Intenta esa prueba una vez más:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
¡Lo hemos conseguido! Ahora podemos devolver diferentes campos objeto por objeto.
Decorar también el desnormalizador
Si quieres añadir también owner:write durante la desnormalización, tendrías que implementar una segunda interfaz. No voy a hacerlo entero... pero implementarías DenormalizerInterface, añadirías los dos métodos necesarios, llamarías al servicio decorado... y cambiarías el argumento para que fuera un tipo de unión deNormalizerInterface y DenormalizerInterface.
Por último, el servicio que estás decorando para la desnormalización es diferente: esapi_platform.serializer.normalizer.item. Sin embargo, si quieres decorar tanto el normalizador como el desnormalizador en la misma clase, tendrías que eliminar#[AsDecorator] y mover la configuración de la decoración a services.yaml... porque un único servicio no puede decorar dos cosas a la vez. API Platform lo explica en sus documentos.
De acuerdo, voy a deshacer todo eso... y limitarme a añadir owner:read. A continuación: ahora que tenemos un normalizador personalizado, podemos hacer fácilmente locuras como añadir un campo totalmente personalizado a nuestra API que no existe en nuestra clase.
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!