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 SubscribeI want to show you something... kind of experimental. We've been following a strict naming convention inside our API resource classes for the normalization and denormalization groups. For normalization, we're using cheese_listing:read
and for denormalization, cheese_listing:write
. When we need even more control, we're adding an operation-specific group like cheese_listing:item:get
.
If you have a lot of different behaviors for each operation, you may end up with a lot of these normalization_context
and denormalization_context
options... which is a bit ugly... but also error prone. When it comes to controlling which fields are and are not exposed to our API, this stuff is important!
So here's my idea: in AdminGroupsContextBuilder
, we have the ability to dynamically add groups. Could we detect that we're normalizing a CheeseListing
item and automatically add the cheese_listing:read
and cheese_listing:item:get
groups? The answer is... of course! But the final solution may not look quite like you expect.
Let's start in AdminGroupsContextBuilder
. At the bottom, I'm going to paste in a new method: private function addDefaultGroups()
. You can copy the method from the code block on this page. This looks at which entity we're working with, whether it's being normalized or denormalized and the exact operation that's currently being executed. It uses this information to always add three groups. The first is easy: {class}:{read/write}
. So user:read
, cheese_listing:read
or cheese_listing:write
. That matches the main groups we've been using.
... lines 1 - 8 | |
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface | |
{ | |
... lines 11 - 37 | |
private function addDefaultGroups(array $context, bool $normalization) | |
{ | |
$resourceClass = $context['resource_class'] ?? null; | |
if (!$resourceClass) { | |
return; | |
} | |
$shortName = (new \ReflectionClass($resourceClass))->getShortName(); | |
$classAlias = strtolower(preg_replace('/[A-Z]/', '_\\0', lcfirst($shortName))); | |
$readOrWrite = $normalization ? 'read' : 'write'; | |
$itemOrCollection = $context['operation_type']; | |
$operationName = $itemOrCollection === 'item' ? $context['item_operation_name'] : $context['collection_operation_name']; | |
return [ | |
// {class}:{read/write} | |
// e.g. user:read | |
sprintf('%s:%s', $classAlias, $readOrWrite), | |
// {class}:{item/collection}:{read/write} | |
// e.g. user:collection:read | |
sprintf('%s:%s:%s', $classAlias, $itemOrCollection, $readOrWrite), | |
// {class}:{item/collection}:{operationName} | |
// e.g. user:collection:get | |
sprintf('%s:%s:%s', $classAlias, $itemOrCollection, $operationName), | |
]; | |
} | |
} |
The next is more specific: the class name, then item
or collection
, which is whether this is an "item operation" or a "collection operation" - then read
or write
. If we're making a GET
request to /api/users
, this would add user:collection:read
.
The last is the most specific... and is kind of redundant unless you create some custom operations. Instead of read
or write
, the last part is the operation name, like user:collection:get
.
To use this method, back up top, add $context['groups'] = $context['groups'] ??
[];. That will make sure that if the groups
key does not exist, it will be added and set to an empty array. Now say $context['groups'] = array_merge()
of $context['groups']
and $this->addDefaultGroups()
, which needs the $context
and whether or not the object is being normalized. So, the $normalization
argument.
... lines 1 - 19 | |
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array | |
{ | |
... lines 22 - 23 | |
$context['groups'] = $context['groups'] ?? []; | |
$context['groups'] = array_merge($context['groups'], $this->addDefaultGroups($context, $normalization)); | |
... lines 26 - 35 | |
} | |
... lines 37 - 65 |
We can remove the $context['groups']
check in the if
statement because it will definitely be set already.
... lines 1 - 19 | |
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array | |
{ | |
... lines 22 - 28 | |
if ($isAdmin) { | |
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write'; | |
} | |
... lines 32 - 35 | |
} | |
... lines 37 - 65 |
Oh, and just to clean things up, let's remove any possible duplications: $context['groups'] = array_unique($context['groups'])
.
... lines 1 - 19 | |
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array | |
{ | |
... lines 22 - 32 | |
$context['groups'] = array_unique($context['groups']); | |
... lines 34 - 35 | |
} | |
... lines 37 - 65 |
That's it! We can now go into CheeseListing
, for example, and remove the normalization and denormalization context options.
... lines 1 - 16 | |
/** | |
* @ApiResource( | |
* itemOperations={ | |
... lines 20 - 25 | |
* }, | |
* collectionOperations={ | |
... lines 28 - 29 | |
* }, | |
* shortName="cheeses", | |
* attributes={ | |
... lines 33 - 34 | |
* } | |
* ) | |
... lines 37 - 46 | |
*/ | |
class CheeseListing | |
... lines 49 - 207 |
In fact, let's prove everything still works by running the tests:
php bin/phpunit
Even though we just drastically changed how the groups are added, everything still works!
So... that was easy, right? Well... remember a few minutes ago when we discovered that the documentation does not see any groups that you add via a context builder? Yep, now that we've removed the normalizationContext
and denormalizationContext
options... our docs are going to start falling apart.
Refresh the docs... and go look at the GET operation for a single CheeseListing
item. This... actually... still shows the correct fields. That's because we're still manually - and now redundantly - setting the normalization_context
for that one operation.
But if you look at the collection GET operation... it says it will return everything: id
, title
, description
, shortDescription
, price
, createdAt
, createdAtAgo
, isPublished
and owner
. Spoiler alert: it will not actually return all of those fields.
If you try the operation... and hit Execute... it only returns the fields we expect. So... we've added these "automatic" groups... which is kinda nice. But we've positively destroyed our documentation. Can we have both automatic groups and good documentation? Yes! By leveraging something called a resource metadata factory: a wild, low-level, advanced feature of API Platform.
Let's dig into that next.
Hey, everyone!
Man, I love this.
Even better would be to iterate over all the user roles and create the respective groups, instead of just checking for the ROLE_ADMIN.
Something like:
role_admin:read
role_admin:write
role_admin:item:get
role_admin:item:patch
role_admin:item:delete
role_admin:collection:post
role_user:read
role_user:item:get
...
It gives full control and it is easier to understand who has access to what, in my opinion.
Do you think that is OK or is it overkill?
Amazing work!
Hey André P.
Honestly it's totally depends on project you work, sometimes it's enough to just check ROLE_ADMIN, but sometimes you may need to make more complex checks it's totally up to you and your project =)
PS do not do something complex if you don't really need it! Simplicity FTW!
Cheers!
Hey, sadikoff !
Wise words indeed.
I'll keep that in mind. I guess I was hyped with the possibilities, hahah.
Thanks you!
in addDefaultGroups you are sometimes returning nothing as seen in return;
But then you use the same function in array merge which always expects an array, am I wrong or should you at least return empty array in case the resource class is null?
Hey gabb!
Nice catch! You are right! I think PHP is being "friendly" to me here - but I should totally be returning an empty array from addDefaultGroups
instead of nothing/null. Thanks for asking about this :).
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
}
}
Hello ! Shouldn't we instead create a file specifically designed for anything "global" like adding default groups, rather than using it in the AdminGroupsContextBuilder which, as the name suggests, is mostly meant for the admin group?
So we could build a set of files that could be used from project to project without worrying about the rest.
EDIT : Okay sorry, we delete it in the next step. Coooool