Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Automatic Serialization Groups

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

I 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.

Adding the Automatic Groups

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!

Ah! My Documentation

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.

Leave a comment!

6
Login or Register to join the conversation
Kiuega Avatar

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

2 Reply
André P. Avatar
André P. Avatar André P. | posted 2 years ago

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!

Reply

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!

Reply
André P. Avatar
André P. Avatar André P. | sadikoff | posted 2 years ago | edited

Hey, sadikoff !

Wise words indeed.
I'll keep that in mind. I guess I was hyped with the possibilities, hahah.

Thanks you!

Reply

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?

Reply

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!

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
    }
}
userVoice