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 SubscribeWhen 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.
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; | |
} | |
} |
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 coreapi_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.
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!
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
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!
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
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).
<br /><?php<br />final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface, LoggerAwareInterface<br />{}<br />
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!
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
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!
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!
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!
// 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
}
}
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!