Dynamic Groups: Context Builder
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 SubscribeIn DragonTreasure
, find the $isPublished
field. Earlier we added this ApiProperty
security
thing so that the field is only returned for admin users or owners of this treasure. This is a simple and 100% valid way to handle this situation.
However, there is another way to handle fields that should be dynamic based on the current user... and it may or may not have two advantages depending on your situation.
The security Options vs Dynamic Groups
First, check out the documentation. Open the GET endpoint for a single DragonTreasure
. And, even without trying it, you can see that isPublished
is a field that is correctly advertised in our docs.
So, that's good, right? Yea! Well, probably. If isPublished
were truly an internal, admin-only field, we might not want it advertised to the world.
The second possible problem with security
is that, if you have this option on many properties, it's going to run that security check a lot of times when returning a collection of objects. Honestly, that probably won't cause performance issues, but it's something to be aware of.
Inventing New Serialization Groups
To solve these two possible problems - and, honestly, just to learn more about how API Platform works under the hood - I want to show you an alternative solution. Remove the ApiProperty
attribute:
// ... lines 1 - 88 | |
class DragonTreasure | |
{ | |
// ... lines 91 - 129 | |
security: 'is_granted("EDIT", object)') | (|
private bool $isPublished = false; | |
// ... lines 132 - 250 | |
} |
And replace it with two new groups. We're not going to use the normal treasure:read
and treasure:write
... because then the fields would always be part of our API. Instead, use admin:read
and admin:write
:
// ... lines 1 - 88 | |
class DragonTreasure | |
{ | |
// ... lines 91 - 128 | |
'admin:read', 'admin:write']) | ([|
private bool $isPublished = false; | |
// ... lines 131 - 249 | |
} |
This won't work yet... because these groups are never used. But here's the idea: if the current user is an admin, then when we serialize, we'll add these two groups.
The tricky part is, right now, groups are static! We set them way up here on the ApiResource
attribute - or on a specific operation - and that's it! But we can make them dynamic.
Hello ContextBuilder
Internally, API Platform has a system called a context builder, which is responsible for building the normalization or denormalization contexts that are then passed into the serializer. And, we can hook into that to change the context: like to add extra groups.
Let's do it! Over in src/ApiPlatform/
, create a new class called AdminGroupsContextBuilder
... and make this implement SerializerContextBuilderInterface
:
// ... lines 1 - 2 | |
namespace App\ApiPlatform; | |
use ApiPlatform\Serializer\SerializerContextBuilderInterface; | |
// ... lines 6 - 7 | |
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface | |
{ | |
// ... lines 10 - 13 | |
} |
Then, go to "Code"->"Generate" - or Command
+N
on a Mac - and select "Implement methods" to create the one we need: createFromRequest()
:
// ... lines 1 - 5 | |
use Symfony\Component\HttpFoundation\Request; | |
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface | |
{ | |
public function createFromRequest(Request $request, bool $normalization, array $extractedAttributes = null): array | |
{ | |
// TODO: Implement createFromRequest() method. | |
} | |
} |
It's pretty simple: API Platform will call this, pass us the Request
, whether or not we're normalizing or denormalizing... and then we return the context
array that should be passed to the serializer.
Let's do some Decoration!
Like we've seen a few times already, our intention is not to replace the core context builder. Nope, we want the core context builder to do its thing... and then we'll add our own stuff.
To do this, once again, we'll use service decoration. We know how this works: add a __construct()
method that accepts a private SerializerContextBuilderInterface
and I'll call this $decorated
:
// ... lines 1 - 7 | |
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface | |
{ | |
public function __construct(private SerializerContextBuilderInterface $decorated) | |
{ | |
} | |
// ... lines 13 - 20 | |
} |
Then, down here, say $context = this->decorated->createFromRequest()
passing $request
, $normalization
and $extractedAttributes
. Add a dump()
to make sure this is working and return $context
:
// ... lines 1 - 7 | |
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface | |
{ | |
// ... lines 10 - 13 | |
public function createFromRequest(Request $request, bool $normalization, array $extractedAttributes = null): array | |
{ | |
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); | |
dump('I AM WORKING!'); | |
return $context; | |
} | |
} |
To tell Symfony to use our context builder in place of the real one, add our #[AsDecorator()]
.
Here, we need the service ID of whatever the core context builder is. That's something you can find in the docs: it's api_platform.serializer.context_builder
:
// ... lines 1 - 5 | |
use Symfony\Component\DependencyInjection\Attribute\AsDecorator; | |
// ... lines 7 - 8 | |
'api_platform.serializer.context_builder') | (|
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface | |
{ | |
// ... lines 12 - 22 | |
} |
Oh, but be careful when using SerializerContextBuilderInterface
: there are two of them. One of is from GraphQL: make sure you select the one from ApiPlatform\Serializer
, unless you are using GraphQL.
Ok! Let's see if it hits our dump! Run all of our tests: I also want to see which fail:
symfony php bin/phpunit
And... okay! We see the dump a bunch of times, followed by two failures. The first is testAdminCanPatchToEditTreasure
. That's the case we're working on right now. We'll worry about testOwnerCanSeeIsPublishedFieldI
in a minute.
Copy the test method name and rerun that with --filter=
:
symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure
When the Context Builder is Called
Perfect! We see the dump: actually three times, which is interesting. Open up that test so we can see what's going on. Yup! We're making a single PATCH
request to /api/treasure/1
. So, the context builder is called 3 times during just one request?
It is! It's called one time when API Platform is querying and loading the DragonTreasure
from the database. That's... kind of an odd situation because the context is meant to be used for the serializer... but we're simply querying for the object. But anyway, that's the first time.
The next two make sense: it's called when the JSON we're sending is denormalized into the object... and a third time when the final DragonTreasure
is normalized back into JSON.
Anyway, let's hop in and add the dynamic groups. To determine if the user is an admin, add a second constructor argument - private Security
from SecurityBundle
called $security
:
// ... lines 1 - 5 | |
use Symfony\Bundle\SecurityBundle\Security; | |
// ... lines 7 - 9 | |
'api_platform.serializer.context_builder') | (|
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface | |
{ | |
public function __construct(private SerializerContextBuilderInterface $decorated, private Security $security) | |
{ | |
} | |
// ... lines 16 - 26 | |
} |
Then down here, if isset($context['groups'])
and $this->security->isGranted('ROLE_ADMIN')
, then we'll add the groups: $context['groups'][] =
. If we're currently normalizing, add admin:read
else add admin:write
:
// ... lines 1 - 10 | |
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface | |
{ | |
// ... lines 13 - 16 | |
public function createFromRequest(Request $request, bool $normalization, array $extractedAttributes = null): array | |
{ | |
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); | |
if (isset($context['groups']) && $this->security->isGranted('ROLE_ADMIN')) { | |
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write'; | |
} | |
// ... lines 24 - 25 | |
} | |
} |
Now, you might be wondering why we're checking if isset($context['groups'])
. Well, it doesn't apply to our app, but imagine if we were serializing an object that didn't have any groups
on it - like we never set the normalizationContext
on that ApiResource
. In that case, adding these groups
would cause it to return less fields! Remember, if there are no serialization groups, the serializer returns every accessible field. But as soon as you add even one group, it only serializes the things in that one group. So if there aren't any groups
, do nothing and let everything be serialized or deserialized like normal.
Ok! Let's try the test now!
symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure
It passes! The isPublished
field is being returned if we're an admin user. But... go refresh the docs... and open the GET one treasure endpoint. Now we do not see isPublished
advertised as a field in our docs... even though it will be returned if we're an admin. That might be good or bad. It is possible to make the docs load dynamically based on who is logged in, but that's not something we're going to tackle in this tutorial. We did talk about that in our API platform 2 tutorial... but the config system has changed.
Let's dig into the next method, which tests that an owner can see the isPublished
field. This is currently failing... and it's even trickier than the admin situation because we need to include or not include the isPublished
field on an object-by-object basis.
Does any one know where I can read about how to make API Platform docs load dynamically based on who is logged in? @weaverryan said at the end of the video, "something we're going to tackle in this tutorial"