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 SubscribeOur 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.
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!
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!
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?
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!
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?
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!
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?
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!
// 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
}
}
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
If I change that to self::createForLanguage(Language::PORTUGUESE) it works. I think that it should be configurable.
Thanks