Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Context Builder & Service Decoration

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

When you make a GET request for a collection of users or a single user, API Platform will use the same normalization group: user:read. This means the response will always contain the email, username and cheeseListings fields.

Now we need to do something smarter: we need to be able to also normalize using another group - admin:read - but only if the authenticated user has ROLE_ADMIN. The key to doing this is something called a "context builder".

Remember: when API Platform, or really, when Symfony's serializer goes through its normalization or denormalization process, it has something called a "context", which is a fancy word for "options that are passed to the serializer". The most common "option", or "context" is groups. The context is normally hardcoded via annotations but we can also tweak it dynamically.

Creating the Context Builder

Google for "API Platform context builder" and find the serialization page. If we scroll down a bit... it talks about changing the serialization context dynamically and gives an example. Steal this BookContextBuilder code. This class can live anywhere in src/, but to follow the docs, let's create a Serializer/ directory and a new PHP class inside of that called AdminGroupsContextBuilder... because the purpose of this context builder will be to add an admin:read group or an admin:write group to every resource if the authenticated user is an admin.

Paste the code and rename the class to AdminGroupsContextBuilder.

... lines 1 - 2
namespace App\Serializer;
use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
private $decorated;
private $authorizationChecker;
public function __construct(SerializerContextBuilderInterface $decorated, AuthorizationCheckerInterface $authorizationChecker)
{
$this->decorated = $decorated;
$this->authorizationChecker = $authorizationChecker;
}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
$resourceClass = $context['resource_class'] ?? null;
if ($resourceClass === Book::class && isset($context['groups']) && $this->authorizationChecker->isGranted('ROLE_ADMIN') && false === $normalization) {
$context['groups'][] = 'admin:input';
}
return $context;
}
}

Service Declaration & Decoration

A lot of times in Symfony, when you're "hooking" into some existing process, like hooking into the "context building" process, all you need to do is create a class, make it implement some interface or extend some base class and... boom! Symfony or API Platform magically sees it and uses it. That happened earlier when we created the voter: no config was needed beyond the class itself.

But, that's not true for a context builder: this needs some service config... some interesting service config. Open config/services.yaml and go the bottom. Our new class is already registered as a service... that happens automatically. But we need to override that service definition to add some extra config. Start with the class name... which is also the service id: App\Serializer\AdminGroupsContextBuilder. Then, I'll look back at the docs, copy these three lines... and paste. Change the BookContextBuilder part of the argument to AdminGroupsContextBuilder.

... lines 1 - 8
services:
... lines 10 - 29
App\Serializer\AdminGroupsContextBuilder:
decorates: 'api_platform.serializer.context_builder'
arguments: [ '@App\Serializer\AdminGroupsContextBuilder.inner' ]

If you've never seen this decorates option before, welcome to service decoration! It's a slightly advanced feature of Symfony's container but it is incredibly powerful... and API Platform uses it in several places.

Internally, API Platform already has a "context builder": it has a single service that it calls that's responsible for building the "context" in every situation. That service is what reads our normalizationContext annotation config.

But now, we want to hook into that process. But... we don't want to replace the core functionality. No, we want to add to it. We do this via service decoration. The id of the core "context builder" service is api_platform.serializer.context_builder.

Tip

GraphQL comes with its own services and you would need to use api_platform.graphql.serializer.context_builder service instead.

So our config says:

Please register a new service called App\Serializer\AdminGroupsContextBuilder and make it replace the core api_platform.serializer.context_builder service.

Yep, this means that, whenever API Platform needs to build the "context", it will now use our service instead of the original, core service. If we only did this, our class would replace the core class - not something we want. Fortunately, the decoration feature allows us to pass the original service as an argument, by using the same id as our service plus .inner. Yep, this weird string is a magic way to reference the original, core context builder service.

If you look in AdminGroupsContextBuilder, it implements SerializerContextBuilderInterface. That's the interface we must implement to be a "context builder". The first constructor argument also implements SerializerContextBuilderInterface and is called $decorated. This is the original, core API Platform context builder service.

The only method this interface requires is createFromRequest(), which API Platform calls when it's building the context. Check out that first line: $context = $this->decorated->createFromRequest().

Yep, we're calling the core context builder, passing it all the arguments, and letting it do its normal logic, like reading the normalizationContext and denormalizationContext config off of our annotations. Then, below this, we can extend the context with our own logic.

Phew! This may look complicated the first time you see it, but I love this feature. On an object-oriented level, this is the "decorator" pattern: the recommended way to "extend" the functionality of a class. The config in services.yaml is Symfony's way of letting you "decorate" any core service.

At this point, our service is being used as the context builder. Next, let's fill in our custom logic to add the dynamic groups.

Leave a comment!

11
Login or Register to join the conversation
Denis V. Avatar
Denis V. Avatar Denis V. | posted 2 years ago

Hi, what is the reason we use `autoconfigure: false` in the service configuration? I mean, I do understand in general what it means, but why here? Thank you!

Reply

Hey Denis V.!

Excellent question. So... basically the docs are wrong here and it's *not* needed. I "caught" this when I built the code for the tutorial (which is why you don't see it included on the code block on this page) but forgot to mention & remove it in the video. Basically, API Platform does *not* register any auto-configuration rules for SerializerContextBuilderInterface... and so the autoconfigure: false is meaningless. Iirc, I asked @dunglas about this to verify and he agreed.

Cheers!

Reply
Default user avatar
Default user avatar Mostafa Shahverdy | posted 2 years ago

It took me a day to find out that for GraphQL we need to use api_platform.graphql.serializer.context_builder instead of api_platform.serializer.context_builder

Reply

Hey Mostafa Shahverdy

Sorry for the late reply and for the confusion. ApiPlatform comes with different implementations for working with GraphQL as you already discovered. We'll add a note about it to the tutorial. Thanks for sharing it :)

Cheers!

1 Reply
Dang Avatar

I haved changed to api_platform.graphql.serializer.context_builder but the query have error on phoneNumber when it set dynamically for admin. Do you have the same problem when using GraphQL and your solution? Thanks

Reply
Mohamed Avatar
Mohamed Avatar Mohamed | posted 3 years ago

Hi

I have been trying to implement the "LoggerAwareInterface" with ContextBuilder and getting an error "Error: Call to a member function debug() on null". It seems for some reason LoggerAwareInterface not working with decorators (I am not quiet sure though).


Reply

Hey Mohamed

I believe it should work when decorating a service but I'm not 100% sure. Can you give it a try with another service? Also, could yo show me your code?

Cheers!

1 Reply
Mohamed Avatar

Hi

Thanks for the quick response. Here is the gist....

https://gist.github.com/zsp...

UserDenormalizer.php works fine!
SuperAdminGroupContextBuilder.php not working and throwing the following error...

"Call to a member function debug() on null"

(ofcourse there are other work arounds but I just want to make sure it's not a bug)

Thank you

Reply

Hey Mohamed

The problem is that you disabled the autoconfiguration on your service. Is there any particular reason?
If you really need to disable it, then what you can do is to use Dependency Injection instead of relying on the interface magic. You can find some more info here: https://symfony.com/doc/cur...

Cheers!

1 Reply
Mohamed Avatar

Hi

Excellent!!! no particular reason except for I followed the exact same steps provided by the api platform documentation and this video tutorial. (https://api-platform.com/do... and video timeline ~03:00). Yes, I am already using dependency injection and it works fine.

So now I am not quite sure why the document example disables it. I will find out!

Thank you very much for your quick response and support. Very helpful!

Cheers!

Reply

Ohh, that's interesting. I don't know either why they disable it. Probably a mistake? If you find out the reason, pleas let us know!

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3, <8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.5
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.6
        "nesbot/carbon": "^2.17", // 2.21.3
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.3.*", // v4.3.2
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/expression-language": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.18.7
        "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/security-bundle": "4.3.*", // v4.3.2
        "symfony/twig-bundle": "4.3.*", // v4.3.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // 2.7.3
        "symfony/browser-kit": "4.3.*", // v4.3.3
        "symfony/css-selector": "4.3.*", // v4.3.3
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/phpunit-bridge": "^4.3", // v4.3.3
        "symfony/stopwatch": "4.3.*", // v4.3.2
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.2
    }
}