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

Dynamic Groups without Caching

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

Our resource metadata factory is now automatically adding a specific set of normalization and denormalization groups to each operation of each resource. That means that we can customize which fields are readable and writable for each operation just by adding specific groups to every property. And the true bonus is that... our documentation is aware of these dynamic groups! It correctly tells us which fields are readable and writable.

But... if you're coding along... it's possible that your docs did not update. If that happened, the fix is to run:

php bin/console cache:clear

Here's the deal: the results of AutoGroupResourceMetadataFactory are cached... which makes sense: the ApiResource options should only need to be loaded one time... then cached. Unfortunately, for right now, this means that each time you make any change to this class, you need to manually rebuild your cache.

Changing CheeseListing shortName & Groups

But before we worry about that... all of our CheeseListing operations are... wait for it... broken! Yay! Check out the GET operation for the collection of cheese listings. It says that it will return an array of... nothing! And in fact... if you tried it, it would indeed return an array where each CheeseListing contains no fields!

This is a small detail related to how our resource metadata factory names the groups: it uses the API resource "short name" for each group - like user:read. What is this shortName thing? For CheeseListing, it comes from the shortName option inside the annotation. Or, if you don't have this option - API Platform guesses a shortName based on the class name.

The shortName most importantly becomes part of the URL. Check this out: execute a GET request to /api/cheeses. Then, use the web debug toolbar to open the profiler for that request... and go to the API Platform section. This shows you the "Resource Metadata" for CheeseListing. Hey, "Resource Metadata" - that's the name of the class that our resource metadata factory is creating!

Look at the normalization_context of the item GET operation: it still has cheese_listing:read and cheese_listing:item:get... because we still have those groups manually on the annotation... which we really should remove now. Then our resource metadata factory added 3 new groups: cheeses:read, cheeses:item:read and cheeses:item:get.

Basically, the group names in the new system - like cheeses:read - don't quite match the group names that we've been using so far - like cheese_listing:read. No worries, we just need to update our code to use the new group names.

But first, we added the shortName option in part 1 of this tutorial to change the URLs from /api/cheese_listings to /api/cheeses. Now, change the shortName to just cheese.

... lines 1 - 16
/**
* @ApiResource(
... lines 19 - 30
* shortName="cheese",
... lines 32 - 35
* )
... lines 37 - 46
*/
class CheeseListing
... lines 49 - 207

If you refresh the docs... surprise! All of the URLs are still /api/cheeses: API Platform automatically takes the shortName and makes it plural when creating the URLs. So, this change... didn't really.. change anything! I did it just so we could keep all of our group names singular. I'll do a find and replace to change cheese_listing: to cheese:.

... lines 1 - 47
class CheeseListing
{
... lines 50 - 56
/**
... line 58
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... lines 60 - 65
*/
private $title;
... line 68
/**
... line 70
* @Groups({"cheese:read"})
... line 72
*/
private $description;
... line 75
/**
... lines 77 - 79
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... line 81
*/
private $price;
... lines 84 - 94
/**
... lines 96 - 97
* @Groups({"cheese:read", "cheese:write"})
... line 99
*/
private $owner;
... lines 102 - 123
/**
* @Groups("cheese:read")
*/
public function getShortDescription(): ?string
... lines 128 - 142
/**
... lines 144 - 145
* @Groups({"cheese:write", "user:write"})
... line 147
*/
public function setTextDescription(string $description): self
... lines 150 - 172
/**
... lines 174 - 175
* @Groups("cheese:read")
*/
public function getCreatedAtAgo(): string
... lines 179 - 205
}

Then, in User, there's one other spot we need to change. Above username, change this to cheese:item:get. Don't forget to also change cheese_listing:write to cheese:write. I'll catch that mistake a bit later.

... lines 1 - 37
class User implements UserInterface
{
... lines 40 - 66
/**
... line 68
* @Groups({"user:read", "user:write", "cheese:item:get", "cheese:write"})
... line 70
*/
private $username;
... lines 73 - 237
}

Phew! Ok, go refresh the docs... and open the GET operation for /api/cheeses. Yay! It properly advertises that it will return an array of the correct fields. And when we try it... hey! It even works!

Making our Resource Metadata Factory Not Cached

This whole resource metadata factory thing is super low-level in API Platform... but it is a nice way to add dynamic groups and have your docs reflect those changes. The only problem is the one I mentioned a few minutes ago: the results are cached... even in the dev environment. So if you tweak any logic inside this class, you'll need to manually clear your cache after every change. It's not the end of the world... but it is annoying.

And... ya know what? It might be more than simply "annoying". What if you wanted to add a dynamic normalization group based on who is logged in and you wanted the documentation to automatically update when the user is logged in to reflect that dynamic group? We already know that a context builder can add a dynamic group... but the docs won't update. But... because our resource metadata factory is cached... we can't put the logic there either: it would load the metadata just once, then use the same, cached metadata for everyone. It would not change after the user logged in.

But what if our resource metadata factory... wasn't cached? Duh, duh, duh!

Service Decoration Priority

Check this out: in config/services.yaml, add a new option to the service: decoration_priority set to -20.

... lines 1 - 8
services:
... lines 10 - 33
App\ApiPlatform\AutoGroupResourceMetadataFactory:
... line 35
# causes this to decorate around the cached factory so that
# our service is never cached (which, of course, can have performance
# implications!
decoration_priority: -20
... lines 40 - 41

Wow... yea... we just took an already-advanced concept and... went even deeper. When we decorate a core service, we might not be the only service decorating it: there may be multiple levels of decoration. And... that's fine! Symfony handles all of that behind the scenes.

In the case of the resource metadata factory, API Platform itself decorates that service multiple times... each "layer" adding a bit more functionality. Normally, when we decorate a service from our application, our object becomes the outermost object in the chain. One of the other services that decorates the core service and is part of that chain is called CachedResourceMetadataFactory. You can probably guess what it does: it calls the core resource metadata factory, gets the result, then caches it.

So... why is this a problem? If we are the outermost resource metadata factory, then... even if the CachedResourceMetadataFactory caches the core metadata, our function would still always be called... and our changes should never be cached. But... that is not what's happening!

Why? Because that CachedResourceMetadataFactory has a decoration_priority of -10... and the default is 0. Before we added the decoration_priority option, this meant that Symfony made CachedResourceMetadataFactory the first object in the decoration chain and our class the second. And that caused our class's results to be cached. By setting our decoration_priority to -20, our object is moved before CachedResourceMetadataFactory... and suddenly, our results are no longer cached.

Crazy, right? We can now put whatever dynamic logic we want into our custom resource metadata factory. Refresh the docs... and look down on the models. Yep, no surprises. Now go into our class and add FOOO to the end of one of the groups. If we had made this tweak a minute ago and refreshed without clearing the cache, we would have seen no changes. But now... it's there instantly! All the core logic that reads the annotations is still cached, but our class is not.

Just... be careful with this: the reason the logic is normally cached is that API Platform calls this function many times during a request. So, any logic you add here needs to be lightning quick. You may even decide to add a private $resourceMetadata array property where you store the ResourceMetadata object for each class as you calculate it. Then, if create() is called on the same request for the same $resourceClass, you can return it from this array instead of running our logic over and over again.

Ok team, I hope you enjoyed this crazy dive into custom resource metadata factories. Next, we know how to hide or show a field based on who is logged in - like returning the phoneNumber field only if the user has ROLE_ADMIN. But what if we also need to hide or show a field based on which object is being serialized? What if we also want to return the phoneNumber field when an authenticated user is fetching their own User data?

Leave a comment!

6
Login or Register to join the conversation
Carlos Avatar
Carlos Avatar Carlos | posted 2 years ago | edited

Hi!!! I'm trying to find out how to pluralize the words to our native language, Portuguese. It seems to be related to the class in doctrine/inflector/lib/Doctrine/Inflector/InflectorFactory.php. But it is hardcoded there:

`public static function create() : LanguageInflectorFactory

{
    return self::createForLanguage(Language::ENGLISH);
}`

If I change that to self::createForLanguage(Language::PORTUGUESE) it works. I think that it should be configurable.

Thanks

1 Reply

Hey Carlos,

Yes, I think you need to call that "createForLanguage()" method directly and pass the language you want. That method is also public, so you can call it on that object. The "create()" method is really hardcoded to the English version.

Cheers!

1 Reply
Carlos Avatar

Hey Victor, thank you! But where could I call this method? Because in anywhere I'm calling the create() method. It's part of the configuration. I couldn't find in the Api Platform docs where to change this. I searched too in the Doctrine and Symfony docs, but actually, in the Symfony docs it says that now the Inflector Component is deprecated (https://symfony.com/doc/cur.... Maybe there's a way to configure through yaml, or maybe through decoration?

Reply

Hey Carlos!

Happy new year!

Yea, I see the issue. First, it's not the Symfony inflector we're dealing with - but the Doctrine one... not that this helps us really (just noting that for clarity). Inside API Platform, I don't see any hook points: they call the Inflector statically in a few places.

But, don't despair! Even though we can't override/configure this InflectorFactory directly, we CAN configure/control the things that use it. And actually, I only the pluralize method used in 2 places:

A) RouteNameGenerator
B) DashPathSegmentNameGenerator and UnderscorePathSegmentNameGenerator (the 2nd is the default, iirc)

My guess is that what you want is to control how the URLs are generated (e.g. Product class -> /api/products) is that correct? If so, then you want to override the "path name segment generator". We talk about that (kinda) here: https://symfonycasts.com/screencast/api-platform-extending/custom-resource#codeblock-0afd22e71b

What I would do is:

Create a class that implements PathSegmentNameGeneratorInterface and decorates the api_platform.path_segment_name_generator.dash service. Then, configure YOUR new service under the api_platform. path_segment_name_generator config.

Let me know if that helps!

Cheers!

1 Reply
Carlos Avatar

Hey Ryan, thanks again. Actually the only problem is if I choose to go with the plurals in the resources. I think that I will set the "path" inside each Entity and stick with the singular words. Can you tell me your opinion, would I lose something doing that?

Reply

Hey Carlos!

I don't see any issue with being explicit and putting the "path" inside each entity - just more work for you (and, of course, you should be careful to be consistent of course!) but always a solid thing to do :).

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