Resource Metadata Factory: Dynamic ApiResource Options

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

Using a context builder to dynamically add groups is a great option when the groups you're adding are contextual to who is authenticated... like we add admin:read only if the user is an admin. That's because the context builder isn't taken into account when your documentation is built. For these "extra" admin fields... that may not be a huge deal. But the more you put into the context builder, the less perfect your docs become.

However, if you're using a context builder to do something crazy like what we're trying now - adding a bunch of groups in all situations - then things really start to fall apart. Our docs are now very inaccurate for all users.

How can we customize the normalization and denormalization groups and have the docs notice the changes? The answer is with a "resource metadata factory"... which is... at least at first... as dark and scary as the name sounds.

Creating the Resource Metadata Factory

Inside the ApiPlatform/ directory, create a new class called AutoGroupResourceMetadataFactory. Make this implement ResourceMetadataFactoryInterface and then take a break... cause we just created one seriously scary-looking class declaration line.

... lines 1 - 2
namespace App\ApiPlatform;
... line 4
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
... lines 6 - 7
class AutoGroupResourceMetadataFactory implements ResourceMetadataFactoryInterface
{
... lines 10 - 22
}

Next, go to Code -> Generate - or Command+N on a Mac - and select "Implement Methods". This interface only requires one method.

... lines 1 - 5
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
... line 7
class AutoGroupResourceMetadataFactory implements ResourceMetadataFactoryInterface
{
... lines 10 - 16
public function create(string $resourceClass): ResourceMetadata
{
... lines 19 - 21
}
}

So... what the heck does this class do? It's job is pretty simple: given an API Resource class - like App\Entity\User - its job is to read all the API Platform metadata for that class - usually via annotations - and return it as a ResourceMetadata object. Yep, this ResourceMetadata object contains all of the configuration from our ApiResource annotation... which API Platform then uses to power... pretty much everything.

Service Decoration

Just like with the context builder, API Platform only has one core resource metadata factory. This means that instead of, sort of, adding this as some additional resource metadata factory, we need to completely replace the core resource metadata factory with our own. Yep, it's service decoration to the rescue!

The first step to decoration has... nothing to do with Symfony: it's the implementation of the decorator pattern. That sounds fancy. Create a public function __construct() where the first argument will be the "decorated", "core" object. This means that it will have the same interface as this class: ResourceMetadataFactoryInterface $decorated. Hit Alt + Enter and go to "Initialize Fields" to create that property and set it.

... lines 1 - 7
class AutoGroupResourceMetadataFactory implements ResourceMetadataFactoryInterface
{
private $decorated;
... line 11
public function __construct(ResourceMetadataFactoryInterface $decorated)
{
$this->decorated = $decorated;
}
... lines 16 - 22
}

Inside the method, call the decorated class so it can do all the heavy-lifting: $resourceMetadata = $this->decorated->create($resourceClass). Then, return this at the bottom: we won't make any modifications yet.

... lines 1 - 16
public function create(string $resourceClass): ResourceMetadata
{
$resourceMetadata = $this->decorated->create($resourceClass);
return $resourceMetadata;
}
... lines 23 - 24

The second step to decoration is all about Symfony: we need to tell it to use our class as the core "resource metadata factory" instead of the normal one... but to pass us the normal one as our first argument. Open up config/services.yaml. We've done all this before with the context builder: override the App\ApiPlatform\AutoGroupResourceMetadataFactory service... then I'll copy the first two options from above... and paste here. We actually don't need this autoconfigure option - that's a mistake in the documentation. It doesn't hurt... but we don't need it.

Ok, for decoration to work, we need to know what the core service id is that we're replacing. To find this, you'll need to read the docs... or maybe even dig a bit deeper if it's not documented. What we're doing is so advanced that you won't find it on the docs. The service we're decorating is api_platform.metadata.resource.metadata_factory. And for the "inner" thing, copy our service id and paste below to make: @App\ApiPlatform\AutoGroupResourceMetadataFactory.inner.

... lines 1 - 8
services:
... lines 10 - 33
App\ApiPlatform\AutoGroupResourceMetadataFactory:
decorates: 'api_platform.metadata.resource.metadata_factory'
arguments: ['@App\ApiPlatform\AutoGroupResourceMetadataFactory.inner']

Cool! Since our resource metadata factory isn't doing anything yet... everything should still work exactly like before. Let's see if that's true! Find your terminal and run the tests:

php bin/phpunit

And... huh... nothing broke! I, uh... didn't mean to sound so surprised.

Pasting in the Groups Logic

For the guts of this class, I'm going to paste two private functions on the bottom. These are low-level, boring functions that will do the hard work for us: updateContextOnOperations() and getDefaultGroups(), which is nearly identical to the method we copied into our context builder. You can copy both of these from the code block on this page.

... lines 1 - 7
class AutoGroupResourceMetadataFactory implements ResourceMetadataFactoryInterface
{
... lines 10 - 33
private function updateContextOnOperations(array $operations, string $shortName, bool $isItem)
{
foreach ($operations as $operationName => $operationOptions) {
$operationOptions['normalization_context'] = $operationOptions['normalization_context'] ?? [];
$operationOptions['normalization_context']['groups'] = $operationOptions['normalization_context']['groups'] ?? [];
$operationOptions['normalization_context']['groups'] = array_unique(array_merge(
$operationOptions['normalization_context']['groups'],
$this->getDefaultGroups($shortName, true, $isItem, $operationName)
));
$operationOptions['denormalization_context'] = $operationOptions['denormalization_context'] ?? [];
$operationOptions['denormalization_context']['groups'] = $operationOptions['denormalization_context']['groups'] ?? [];
$operationOptions['denormalization_context']['groups'] = array_unique(array_merge(
$operationOptions['denormalization_context']['groups'],
$this->getDefaultGroups($shortName, false, $isItem, $operationName)
));
$operations[$operationName] = $operationOptions;
}
return $operations;
}
private function getDefaultGroups(string $shortName, bool $normalization, bool $isItem, string $operationName)
{
$shortName = strtolower($shortName);
$readOrWrite = $normalization ? 'read' : 'write';
$itemOrCollection = $isItem ? 'item' : 'collection';
return [
// {shortName}:{read/write}
// e.g. user:read
sprintf('%s:%s', $shortName, $readOrWrite),
// {shortName}:{item/collection}:{read/write}
// e.g. user:collection:read
sprintf('%s:%s:%s', $shortName, $itemOrCollection, $readOrWrite),
// {shortName}:{item/collection}:{operationName}
// e.g. user:collection:get
sprintf('%s:%s:%s', $shortName, $itemOrCollection, $operationName),
];
}
}

Next, up in create(), I'll paste in a bit more code.

... lines 1 - 16
public function create(string $resourceClass): ResourceMetadata
{
... lines 19 - 20
$itemOperations = $resourceMetadata->getItemOperations();
$resourceMetadata = $resourceMetadata->withItemOperations(
$this->updateContextOnOperations($itemOperations, $resourceMetadata->getShortName(), true)
);
$collectionOperations = $resourceMetadata->getCollectionOperations();
$resourceMetadata = $resourceMetadata->withCollectionOperations(
$this->updateContextOnOperations($collectionOperations, $resourceMetadata->getShortName(), false)
);
... lines 30 - 31
}
... lines 33 - 77

This is way more code than I normally like to paste in magically... but adding all the groups requires some pretty ugly & boring code. We start by getting the ResourceMetadata object from the core, decorated resource metadata factory. That ResourceMetadata object has a method on it called getItemOperations(), which returns an array of configuration that matches the itemOperations for whatever resource we're working on. Next, I call the updateContextOnOperations() method down here, which contains all the big, hairy code to loop over the different operations and make sure the normalization_context has our "automatic groups"... and that the denormalization_context also has the automatic groups.

The end result is that, by the bottom of this function, the ResourceMetadata object contains all the "automatic" groups we want for all the operations. Honestly, this whole idea is... kind of an experiment... and there might even be some subtle bug in my logic. But... it should work.

And thanks to this new stuff, the code in AdminGroupsContextBuilder is redundant: remove the private function on the bottom... and the line on top that called it.

... lines 1 - 8
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 11 - 19
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
$context['groups'] = $context['groups'] ?? [];
$isAdmin = $this->authorizationChecker->isGranted('ROLE_ADMIN');
if ($isAdmin) {
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write';
}
$context['groups'] = array_unique($context['groups']);
return $context;
}
}

Ok... let's see what happens! Refresh the docs. The first thing you'll notice is on the bottom: there are now tons of models! This is the downside of this approach: it's total overkill for the models: Swagger shows every possible combination of the groups... even if none of our operations uses them.

Let's look at a specific operation - like GETing the collection of cheeses. Oh... actually - that's not a good example - the CheeseListing resource is temporarily broken - I'll show you why in a few minutes. Let's check out a User operation instead. Yep! It shows us exactly what we're going to get back.

So... we did it! We added dynamic groups that our API documentation knows about. Except... there are a few problems. It's possible that when you refreshed your docs, this did not work for you... due to caching. Let's talk more about that next and fix the CheeseListing resource.

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
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "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.9.10
        "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
    }
}