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!

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