Lucky you! You found an early release chapter - it will be fully polished and published shortly!
Rest assured, the gnomes are hard at work
completing this video!
Our 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.
And we know decoration! Add public function __construct()
with
private NormalizerInterface $normalizer
.
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.
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.
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
,AddOwnerGroupsNormalizer
given.
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!
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
.
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. There we go.
Inside, say, if $this->normalizer
is an instanceof SerializerAwareInterface
,
then call $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!
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... 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
.
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.
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.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}