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!

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.21.3
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.4.5
        "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/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // v2.5.1
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}