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

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!

37
Login or Register to join the conversation
ties8 Avatar

Hello,

Since API Platorm 2.7 ResourceMetadataFactoryInterface is deprecated and has been replaced with ResourceMetadataCollectionFactoryInterface.
I havent come arround to explore the new Interface, but it seems to be more than just a "switch interface and yeet" kind of update.
Are there any plans to update this course to news inside API Platform?

Reply
Micoto Avatar

Created a working update for the Metadata changes for 3.0 while coding along, slightly refactored to work with the new Collection interface. It may help someone else too. Differences = Class name & implementation, HTTP verb from the operation method (the operation name is not really usable unless you play with regex), also changed method name of the helper to align with the API Platform naming convention.

<?php

namespace App\ApiPlatform;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;

class AutoGroupResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
{
    private $decorated;

    public function __construct(ResourceMetadataCollectionFactoryInterface $decorated)
    {
        $this->decorated = $decorated;
    }

    public function create(string $resourceClass): ResourceMetadataCollection
    {
        $resourceMetadataCollection = $this->decorated->create($resourceClass);

        foreach($resourceMetadataCollection as $key => $resourceMetadata) {
            if ($resourceMetadata->getOperations()) {
                $resourceMetadata = $resourceMetadata->withOperations($this->getTransformedOperations($resourceMetadata->getOperations(), $resourceMetadata));
            }

            $resourceMetadataCollection[$key] = $resourceMetadata;
        }
        return $resourceMetadataCollection;
    }

    private function getTransformedOperations(Operations|array $operations, ApiResource $resourceMetadata)
    {
        foreach ($operations as $key => $operation) {
            $isCollection = $operation instanceof CollectionOperationInterface;;

            $operation = $operation->withNormalizationContext(['groups' => array_unique(array_merge(
                $operation->getNormalizationContext()['groups'] ?? [],
                $this->getDefaultGroups($resourceMetadata->getShortName(), true, $isCollection, $operation->getMethod())
            ))]);

            $operation = $operation->withDenormalizationContext(['groups' => array_unique(array_merge(
                $operation->getDenormalizationContext()['groups'] ?? [],
                $this->getDefaultGroups($resourceMetadata->getShortName(), false, $isCollection, $operation->getMethod())
            ))]);

            $operations instanceof Operations ? $operations->add($key, $operation) : $operations[$key] = $operation;
        }

        return $operations;

    }


    private function getDefaultGroups(string $shortName, bool $normalization, bool $isCollection, string $method)
    {
        $shortName = strtolower($shortName);
        $readOrWrite = $normalization ? 'read' : 'write';
        $itemOrCollection = $isCollection ? 'collection' : 'item';

        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, strtolower($method)),
        ];
    }
}
2 Reply

@Micoto THANK YOU! This was SUPER helpful! I expanded on your logic a little.

I added another private function so it was easy to modify any of the Operation settings. As well added a custom exception class.

<?php

namespace App\ApiPlatform;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use App\Exception\OperationMethodNotFoundException;

class AutoGroupResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
{
    private $decorated;

    public function __construct(ResourceMetadataCollectionFactoryInterface $decorated)
    {
        $this->decorated = $decorated;
    }

    public function create(string $resourceClass): ResourceMetadataCollection
    {
        $resourceMetadataCollection = $this->decorated->create($resourceClass);

        foreach($resourceMetadataCollection as $key => $resourceMetadata) {
            if ($resourceMetadata->getOperations()) {
                $resourceMetadata = $resourceMetadata->withOperations($this->getTransformedOperations($resourceMetadata->getOperations(), $resourceMetadata));
            }

            $resourceMetadataCollection[$key] = $resourceMetadata;
        }
        return $resourceMetadataCollection;
    }

    private function getTransformedOperations(Operations|array $operations, ApiResource $resourceMetadata)
    {
        foreach ($operations as $key => $operation) {
            $isCollection = $operation instanceof CollectionOperationInterface;;

            $operation = $this->withOperationMethod('NormalizationContext',$operation,['groups' => array_unique(array_merge(
                $operation->getNormalizationContext()['groups'] ?? [],
                $this->getDefaultGroups($resourceMetadata->getShortName(), true, $isCollection, $operation->getMethod())
            ))]);

            $operation = $this->withOperationMethod('NormalizationContext',$operation,['groups' => array_unique(array_merge(
                $operation->getNormalizationContext()['groups'] ?? [],
                $this->getDefaultGroups($resourceMetadata->getShortName(), true, $isCollection, $operation->getMethod())
            ))]);
            $operation = $this->withOperationMethod('NormalizationContext',$operation,['groups' => array_unique(array_merge(
                $operation->getNormalizationContext()['groups'] ?? [],
                $this->getDefaultGroups($resourceMetadata->getShortName(), true, $isCollection, $operation->getMethod())
            ))]);


            $operations instanceof Operations ? $operations->add($key, $operation) : $operations[$key] = $operation;
        }

        return $operations;

    }


    private function getDefaultGroups(string $shortName, bool $normalization, bool $isCollection, string $method)
    {
        $shortName = strtolower($shortName);
        $readOrWrite = $normalization ? 'read' : 'write';
        $itemOrCollection = $isCollection ? 'collection' : 'item';

        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, strtolower($method)),
        ];
    }

    private function withOperationMethod(string $method, HttpOperation $operation, array $config)
    {
        if (!str_starts_with($method,'with')) {
            $method = 'with'.$method;
        }

        if(!method_exists($operation,$method)) {
            throw new OperationMethodNotFoundException();
        }

        return $operation->$method(array_unique(array_merge(
            $operation->getOpenapiContext() ?? [],
            $config
        )));
    }
}
Reply

This is awesome! Thank you for posting it! ❤️

Reply

Hey @ties8

I havent come arround to explore the new Interface, but it seems to be more than just a "switch interface and yeet" kind of update.

I think so too!

Are there any plans to update this course to news inside API Platform?

Yup :). It's high on my list to get a new series re-recorded using fresh API Platform 3!

Cheers!

Reply
Demcy Avatar
Demcy Avatar Demcy | posted 1 year ago | edited

Hi,

I totally get confused. Why I need context builder file if all logic now in metadata factory?
I add extra code to check admin role and docs and implementation work well without context builder


$operationOptions['normalization_context'] = $operationOptions['normalization_context'] ?? [];
$operationOptions['normalization_context']['groups'] = $operationOptions['normalization_context']['groups'] ?? [];
if ( $this->authorizationChecker->isGranted('ROLE_ADMIN') ) {
    $operationOptions['normalization_context']['groups'] = ['admin:read'];
}
$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'] ?? [];
if ( $this->authorizationChecker->isGranted('ROLE_ADMIN') ) {
    $operationOptions['denormalization_context']['groups'] = ['admin:write'];
}
$operationOptions['denormalization_context']['groups'] = array_unique(array_merge(
    $operationOptions['denormalization_context']['groups'],
    $this->getDefaultGroups($shortName, false, $isItem, $operationName)
));
$operations[$operationName] = $operationOptions;
Reply

Hey @Александр Хохлачёв!

Hmm, that's interesting! So the issue is (or "should be" and was when I built this tutorial) that the metadata factory is called while your container is compiling. In other words, in production, this code would be run just ONCE (probably during "bin/console cache:warmup") and its results are stored in the container statically. This is not a method that is called on every request.

So, either that has changed. OR, you're getting lucky: when you refresh (or make a request), the container is rebuilding on that request, which reads that you are currently an admin, and builds the cache using that data. If that's the case, after getting it to work once, I would then try to make a request (or refresh) as an anonymous user to see if you are *still* seeing the fields from admin:read/admin:write.

Let me know what you find out :).

Cheers!

2 Reply
Demcy Avatar

Hi,
yes it work on refresh and rebuild showed docs

Reply

Hey @Александр Хохлачёв!

Cool! Then do it the way you've done it :). When I originally coded this, the metadata factory was not called on every request, and so couldn't contain any logic about the request of the user.

Cheers!

Reply
php-programmist Avatar
php-programmist Avatar php-programmist | posted 1 year ago

Hi!
I am adding groups dynamicaly depending of user roles with help of ContextBuilder. For example:
user:read:admin
user:read:editor
user:read:manager

Every thing works fine except docs. I thought I could have correct documentation for logged in user.

I added session based authentication for the docs page, and I see logged in user in debug pannel.

I thought I could use same trick with Resource Metadata Factory as with ContextBuilder. I injected Security sevice and call isGranted method. But I got error:


The token storage contains no authentication token. One possible reason may be that there is no firewall configured for this URL in . (which is being imported from "/opt/core/config/routes/api_platform.yaml"). Make sure there is a loader supporting the "api_platform" type.

When I dumped $this->security->getUser(), I got null :(

Is there any possibility to get logged user in Resource Metadata Factory class?

Reply

Hey php-programmist!

This is a really excellent question :). The problem is that the "resource metadata" (i.e. the collection of all of the API Platform annotations, property information, groups that are on each property, etc) is built during "compile" time of the container. So, it's built just *once* "per deploy" and it's built before it ever handles the first request. This is by design for performance: there's no reason to be re-parsing metadata on every request.

So... this means that we can't make the Resource Metadata Factory dynamic based on anything related to the request of the user. Instead, the way to customize the documentation based on the user is to "decorate" that class that builds the documentation. The process looks like this:

A) To build the documentation, it actually builds a JSON representation of the "documentation". In the same way that you might have a CheeseListing, which goes through the normalization a& serialization process to be turned into JSON, the documentation itself goes a normalization process - specifically it uses a class called DocumentationNormalizer - https://github.com/api-plat...

B) Ultimately, the DocumentationNormalizer reads information from the resource metadata factory and eventually returns a huge array of Open API data (which is later turned into JSON). The Swagger UI you see is built on this.

So by hooking into the DocumentationNormalizer, you can change the OpenAPI JSON that's returned based on the user... and thus "affect" the Swagger documentation. I've never personally done this - and my guess is that it's non-trivial, but this is the path :).

Let me know if you have any success.

Cheers!

Reply
Default user avatar
Default user avatar Jascha Lukas Gugat | weaverryan | posted 1 year ago | edited

Sorry, something might have gone wrong with me previous post... it doesn't show up. I think it is possible to keep the docs correct at least in terms of user dependent fields which is also mentioned in the second paragraph of the next chapter <a href="https://symfonycasts.com/screencast/api-platform-security/uncached-metadata#making-our-resource-metadata-factory-not-cached&quot;&gt;&quot;Making our Resource Metadata Factory Not Cached"</a>.

I have implemented a getRoleSpecificGroups() function in the AutoGroupResourceMetadataFactory which first checks the tokenStorage because the function is called during compilation and no token exists. If a user is authenticated it adds the role prefix (admin:, controller:, etc) to the default groups. The schema and example values are now correct in the swaggerUi and docs.json for users with different roles. Nevertheless im not shure if the hydra-documentation docs.jsonld is correct (i am not so familliar with it) because the admin-client is still broken but i am not sure if its functionality is realy dynamic or just generated at compilation time when no user is logged in. In <a href="https://github.com/api-platform/core/issues/2719#issuecomment-493445142&quot;&gt;https://github.com/api-platform/core/issues/2719#issuecomment-493445142&lt;/a&gt;) the discussion is about completely hide api endpoints for users without privileges which is not supported at the moment and as allready mentioned non-trivial.

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['normalization_context']['groups'] = $this->getRoleSpecificGroups($operationOptions['normalization_context']['groups']);

            $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)
            ));
            $operationOptions['denormalization_context']['groups'] = $this->getRoleSpecificGroups($operationOptions['denormalization_context']['groups']);

            $operations[$operationName] = $operationOptions;
        }

        return $operations;
    }```

    private function getRoleSpecificGroups(array $defaultGroups) {
        if (
            $this->tokenStorage->getToken() && 
            $this->tokenStorage->getToken()->isAuthenticated()
        ) {
            $isAdmin = in_array('ROLE_ADMIN', $this->tokenStorage->getToken()->getRoleNames()) ? true : false;
            $isController = in_array('ROLE_CONTROLLER', $this->tokenStorage->getToken()->getRoleNames()) ? true : false;
            $isTechnician = in_array('ROLE_TECHNICIAN', $this->tokenStorage->getToken()->getRoleNames()) ? true : false;
                        
            $adminGroups = array();
            $controllerGroups = array();
            $technicianGroups = array();
        } else {
            return $defaultGroups;
        }

        if ($isAdmin == true) {
            foreach ($defaultGroups as $defaultGroup) {
                $adminGroups[] = sprintf('%s:%s', 'admin', $defaultGroup);
            }
        }
        if ($isController == true) {
            foreach ($defaultGroups as $defaultGroup) {
                $controllerGroups[] = sprintf('%s:%s', 'controller', $defaultGroup);
            }
        }
        if ($isTechnician == true) {
            foreach ($defaultGroups as $defaultGroup) {
                $technicianGroups[] = sprintf('%s:%s', 'technician', $defaultGroup);
            }
        }
        
        $groups = array_merge($defaultGroups, $adminGroups, $controllerGroups, $technicianGroups);

        return $groups;
        
    }```

Reply

Hey Jascha Lukas Gugat !

Sorry, something might have gone wrong with me previous post... it doesn't show up

Ah, sorry about that - I'm not sure what happened. My guess is that you're referring to the comment that starts with "But I have got another problem". If I'm correct, that IS showing up now - it's possible it was flagged temporarily for some reason.

I think it is possible to keep the docs correct at least in terms of user dependent fields which is also mentioned in the second paragraph of the next chapter "Making our Resource Metadata Factory Not Cached".

Duh, of course! I had honestly completely forgotten about this fact even though it's from my own course. This level of API Platform is very deep :).

Nevertheless im not shure if the hydra-documentation docs.jsonld is correct (i am not so familliar with it) because the admin-client is still broken but i am not sure if its functionality is realy dynamic or just generated at compilation time when no user is logged in

Hmm. I'm not sure. I believe (and you could temporarily hack a die() into this class to be sure) that when the Hydra documentation is generated, the DocumentationNormalizer::normalize() method is called - https://github.com/api-platform/core/blob/86a2a4d1724324cb888f00ba0570dc6072cdb715/src/Hydra/Serializer/DocumentationNormalizer.php#L72 - identical to what happens when you generate the OpenAPI documentation (that happens in a class with the same name - https://github.com/api-platform/core/blob/2.6/src/Swagger/Serializer/DocumentationNormalizer.php).

If I'm correct, then, in theory, you should be able to generate user-specific documentation - that class uses the "resource metadata factory" - https://github.com/api-platform/core/blob/86a2a4d1724324cb888f00ba0570dc6072cdb715/src/Hydra/Serializer/DocumentationNormalizer.php#L78 - just like open api's DocumentationNormalizer does.

So, I don't have an answer for you - but I do have some spots that you could dig into to find out more :).

Cheers!

Reply
Default user avatar
Default user avatar Jascha Lukas Gugat | weaverryan | posted 1 year ago

But i have got another problem:
I am using superclasses and single table inheritance for my entitiies and by using the previously described method with the uncached Ressource Metadata Factory make my documentation works perfectly for all normalization operations (consider all normalization groups from parants, grandparents, etc) but the denormalization groups are only considered from the final entity. It is so curious because the inherited denormalization groups are working as expected for the functionality but not for the generation of the docs and for the normalization groups both works as expected. Is there something i am missing on mapped superclass, inhertiance of desirializaition operations groups, during the documentation generation?

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

Hey, everyone!

So, I'm developing an API and I was hoping if you could help me make a decision.

When it comes to authorization, I wanted to create groups related to user roles, so I could easily, and intuitively, give or take access to a property/operation depending on the user role, even though it may require a lot of groups per property, but I think it is a "necessary evil" if I want to have this kind of versatility. Something like role-billing:read or role-owner:write.

I think that way because, as an example, there may be a ROLE_BILLING that has access only to billing information, a ROLE_MANAGER that can only manage ad campaigns and a ROLE_OWNER that can do both.

The authentication method is through a Bearer Token and it is stateless. Because of this, the documentation will never reflect the "logged in" user, because there is no session and authentication happens via every request. So if I created the groups via the Resource Metadata Factory it would be a mess, basically.

So I was thinking that I could create the role groups via a Context Builder and then groups just for documentation purposes via the Resource Metadata Factory, something like docs:user:read.

That way I could control how I want to show the documentation, and let authorization do the rest, but I fear that I'm overcomplicating things and just creating a mess.

Another way would be to create re-usable Voters for each role and attach them to the properties, but I'm not sure if that is a better solution.

I'm new to Symfony and API Platform, so I still struggle with this decisions because I may not be thinking of the trade-offs in the long term due to my inexperience.

What is your opinion regarding this?

Thank you!

Reply

Hi André P.!

Nice to chat with you! Overall, I like your idea and, indeed, it is quite possibly a "necessary evil" if you want to have this level of flexibility.

One quite note before I answer your other questions: when you add the role groups via the ContextBuilder, IF you've decided to leverage role_hierarchy in security.yaml, then don't forget that you'll need to take that into account. For example, if ROLE_OWNER "inherits" both ROLE_BILLING and ROLE_MANAGER, then you'll want to make sure you "resolve" this so that if I ONLY have ROLE_OWNER, we also correct add ROLE_BILLING and ROLE_MANAGER to the groups. There's a service that can help with this: https://stackoverflow.com/questions/8928085/how-to-retrieve-full-role-hierarchy-in-symfony/49192974#answer-49192974

I like the idea of adding the correct roles to the context (based on the user) in the ContextBuilder. 👍 for that. So it sounds like the only/main problem is how to display the documentation. Is your goal to display ALL the possible fields in the documentation? If so, hmm. Yea, the only - and cleanest way - that I can think to do this is your idea: (A) make sure that every operation has some group - like docs:user:read (which you could do manually, but simpler to do via a resource metadata factory), (B) make sure every field that is EVERY potentially exposed in the API also has this group (I think you would need to do this by hand on each property - you could potentially decorate the serializer's "class metadata factory" service, but you would still need a way to know if a property should EVER be exposed vs is internal... potentially you could do that automatically by checking to see if the property has at LEAST one other group...) and (C) remove the docs:user:read class via a context builder so that this group doesn't actually cause access to the operations and properties.

So, my thinking is that (1) this is indeed pretty complex but that (2) at least I can't think of a better way of doing it :). Do your best to keep it simpler along the way when you can - but this seems like a good direction.

Cheers!

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

Hi weaverryan ,

Sorry for the late reply, but I was quite busy this last few days.

First of all, thank you for noting me about how the role hierarchy inheritance works. It already helped me dealing with voters and leveraging a lot of code. Awesome!

Regarding my initial question, your opinion made me think more about it.

My intention was to show **only** the documentation with the endpoints and data I wanted the "public" to see, but then I realized (like you said in some of your videos) that what I was doing was kind of "security through obscurity", and that makes no sense.

Since the documentation is targeted for development purposes, if I really don't want it to be public I'll just make it accessible in the dev environment.

I'll stick with the role groups created in the ContextBuilder and let the documentation be everything the API has to offer.

Once again, thank you very much for the help!

PS: Love the new dark theme!

1 Reply

Hey André P.

We're happy to know you are liking our dark theme. It required more effort than it may appear :)

Reply
André P. Avatar

Great work, really.
Not sure how to describe it, but feels really smooth, hahah.

I even like the attention to detail of changing the font weight, not just the color.

Once again, great work!

1 Reply
Braunstetter Avatar
Braunstetter Avatar Braunstetter | posted 2 years ago

Hey Ryan! Is there a way to get rid of this model-noise from swagger. I mean, when I offer a company a enterprise level custom API that displays exact their business logic and have nice and exact documentation - this would not be something they would accept so easy in their documentation.

Reply

Hey Michael B.!

Sorry for the slow reply! Yea, that's a great question. I'd say 2 things:

1) The Swagger UI is ultimately generated from your OpenAPI spec. So, in a pinch, you could generate your OpenAPI spec, modify it, then use Swagger UI to read that new spec. I'm leaving out the details, but you probably get the idea on a high level. However, you'd probably need to be careful, if you're removing "excess models", to update other parts of the document that refer to them.

2) The proper fix would be to tell ApiPlatform to stop doing this :). I have NO idea if this is possible - and the logic in this area is pretty complex. I believe the definitions are added here - https://github.com/api-platform/core/blob/a9178865c2074b7db1dcbc5c78cb4fa39f4ada54/src/JsonSchema/SchemaFactory.php#L115 - and to remove the duplicates, you could modify this function - https://github.com/api-platform/core/blob/a9178865c2074b7db1dcbc5c78cb4fa39f4ada54/src/JsonSchema/SchemaFactory.php#L242

Buuuuut, that is (A) just a guess (you could verify by hacking in that function) and (B) that's a private property anyways! Though, it looks like you may be able to leverage this line - https://github.com/api-platform/core/blob/a9178865c2074b7db1dcbc5c78cb4fa39f4ada54/src/JsonSchema/SchemaFactory.php#L256 - by adding a openapi_definition_name option or (swagger_definition_name) as described here - https://api-platform.com/docs/core/openapi/#changing-the-name-of-a-definition - though, looking at the code, I kind of don't see how that all connects (but hopefully I'm missing something).

So, unless I'm missing something, it's not a super great answer. But let me know if this helps.

Cheers!

1 Reply
Kiuega Avatar

Hello ! Do you recommend working with this "generator" on every project with API Platform? Do you ?

Does it cover all the needs that we might have during development? I have heard of "SubResources", I never used them, but if I were to use it, would the generator work?

Or is it still better to do everything by hand?

Thank you !

Reply

Hey Kiuega!

I can't advice you on this with... too much certainty because I haven't used the generator in any significant way. But, my impression is that "yes", it does cover all cases. And of course, it's basically just generating a SDK - a set of tools to help make your life simpler. And so, if it doesn't work for some case, you can always just not use it.

For a bit more context, we have a small microservice architecture here at SymfonyCasts with about 3 microservices that expose an API and are consumed by SymfonyCasts.com and the other microservices. Buuut, all of this predates ApiPlatform (or at least, predates Api Platform v2), and so they don't use ApiPlatform. If I were building them today, I would use ApiPlatform and I would use the code generator. That would give me an SDK & model classes for each API that I could re-use in all the other services that need to talk to it, which is pretty darn convenient. And if I update my API (it's a private API, so I don't have backwards-compatibility concerns), I'd just update the generated SDK!

Anyways, I hope this helps. My advice would be to give it a shot :).

Cheers!

1 Reply
Default user avatar
Default user avatar Philippe Bauwens | posted 2 years ago

Hello,
I have been working with AutoGroup which looks great but I feel it lacks features.
There is no automatic group with the SubResource
If we define groups in normalizationContext and denormalizationContext, these are no longer taken into account, which is managed for example in the case of inheritance or the use of Traits

Reply

Hey Philippe Bauwens!

Yea, this was a bit of an experiment for me - it has its pros and cons.

If we define groups in normalizationContext and denormalizationContext, these are no longer taken into account, which is managed for example in the case of inheritance or the use of Traits

Are you referring to if you added a normalizationContext="" option above the class inside a @ApiResource annotation? If so, I can't remember for sure, but I think it should still work - the resource metadata factory we create - https://symfonycasts.com/screencast/api-platform-security/resource-metadata-factory#codeblock-b610d31864 - is checking for the existence of this config, and then merging the "default" groups into any existing config. But, I think I may not be fully understanding your situation - can you tell me more about how you're using inheritance and traits related to normalizationContext and denormalizationContext.

There is no automatic group with the SubResource

Can you tell me more about what behavior your expecting with sub resource vs what behavior you're seeing? An example would be ideal :).

Cheers!

Reply
Default user avatar
Default user avatar Philippe Bauwens | weaverryan | posted 2 years ago | edited

I am referring to normalizationContext in the annotation indeed. I have done tests and it does not work.

I worked yesterday to review the AutoGroupResourceMetadataFactory class so that it gives me satisfaction. Here is the result.

With these changes, I support the normalizationContext annotation as well as subresourceOperations, in addition I have added the "read" and "write" groups to allow the use of generic cases.
In addition, I personally removed the lowercase setting because I admit that I am not interested in adding a shortname in all of my entities and if I have a "PrivateMessage" entity, have the group all in lowercase is not very readable (question of choice)

The only thing I don't have is an automatic context for subresourceOperations. I don't know how to determine that "api_foo_bar_get_subresource" will be the Foo:read group.


namespace App\ApiPlatform;

use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;

class AutoGroupResourceMetadataFactory implements ResourceMetadataFactoryInterface
{
    public function __construct(
        private ResourceMetadataFactoryInterface $decorated,
    ) {}

    public function create(string $resourceClass): ResourceMetadata
    {
        $resourceMetadata = $this->decorated->create($resourceClass);

        $itemOperations = $resourceMetadata->getItemOperations();
        $itemOperations = $this->updateContext($resourceMetadata, true, $itemOperations, true);
        $itemOperations = $this->updateContext($resourceMetadata, false, $itemOperations, true);
        $resourceMetadata = $resourceMetadata->withItemOperations($itemOperations);

        $collectionOperations = $resourceMetadata->getCollectionOperations();
        $collectionOperations = $this->updateContext($resourceMetadata, true, $collectionOperations, false);
        $collectionOperations = $this->updateContext($resourceMetadata, false, $collectionOperations, false);
        $resourceMetadata = $resourceMetadata->withCollectionOperations($collectionOperations);

        $subResourceOperations = $resourceMetadata->getSubresourceOperations() ?? [];
        $subResourceOperations = $this->updateContext($resourceMetadata, true, $subResourceOperations, false, fn () => ["read"]);
        $resourceMetadata = $resourceMetadata->withSubresourceOperations($subResourceOperations);

        return $resourceMetadata;
    }

    private function updateContext(
        ResourceMetadata $resourceMetadata,
        bool $normalization,
        array $operations,
        bool $isItem,
        ?callable $groupCallback = null,
    ): array
    {
        if ( is_null($groupCallback) ) {
            $groupCallback = [$this, "getDefaultGroups"];
        }

        $context = $normalization ? "normalization_context" : "denormalization_context";

        $attributeContext = $resourceMetadata->getAttribute($context) ?? [];
        $attributeContext['groups'] = $attributeContext['groups'] ?? [];

        foreach ($operations as $operationName => $operationOptions) {
            $operationOptions[$context] = $operationOptions[$context] ?? [];
            $operationOptions[$context]['groups'] = $operationOptions[$context]['groups'] ?? [];
            $operationOptions[$context]['groups'] = array_unique(array_merge(
                $attributeContext['groups'],
                $operationOptions[$context]['groups'],
                $groupCallback($resourceMetadata->getShortName(), $normalization, $isItem, $operationName),
            ));

            $operations[$operationName] = $operationOptions;
        }

        return $operations;
    }

    private function getDefaultGroups(string $shortName, bool $normalization, bool $isItem, string $operationName): array
    {
        //$shortName = strtolower($shortName);
        $readOrWrite = $normalization ? 'read' : 'write';
        $itemOrCollection = $isItem ? 'item' : 'collection';

        return [
            // {read/write}
            // e.g. read
            $readOrWrite,
            // {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),
        ];
    }
}

</code

(I don't know why my previous comment was marked as spam)

Reply

Hi Philippe Bauwens!

Sweet! Thanks for sharing your improved version of the auto groups class :). Unfortunately, I don't work with sub-resources too often, so I'm also not sure about that part either :/.

Cheers!

Reply

Hi I am following the tutorial and applying my own logic, based on what I am building, but when I configure the service in services.yaml and it looks like this


App\ApiPlatform\AutoGroupResourceMetadataFactory:
        decorates: 'api_platform.metadata.resource.metadata_factory'
        arguments: ['@App\ApiPlatform\AutoGroupResourceMetadataFactory.inner']

and mi AutoGroupResourceMetadataFactory class looks like this


namespace App\ApiPlatform;

use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;

class AutoGroupResourceMetadataFactory implements ResourceMetadataFactoryInterface
{
    private $decorated;

    public function __construct(ResourceMetadataFactoryInterface $decorated)
    {
        $this->$decorated = $decorated; 
    }

    public function create(string $resourceClass): ResourceMetadata
    {
        $resourceMetadata = $this->decorated->create($resourceClass);
        return $resourceMetadata;
    }
}

but when I run my unit tests they all give me the following error:
Error: Object of class ApiPlatform\Core\Metadata\Resource\Factory\FormatsResourceMetadataFactory could not be converted to string

and if I unconfigure the service (that is, I comment it on service.yaml) all the tests run fine... Any advice :( Regards and thanks in advance...!!!

Reply

Hi Toshiro!

Nice to chat with you! Oh, and I see the error and it's SO subtle. In your class, here it is:


// you have
$this->$decorated = $decorated;

// should be
$this->decorated = $decorated;

See it? You have an extra $ sign. Unfortunately, PHP is so friendly that this is allowed. But it looks like you're trying to convert the $decorated argument into a string... so that you can then call a dynamic method that matches that strings name on $this. That's why you see that exact error 🙃

Anyways, get rid of that extra $, and you're going to be very happy :).

Cheers!

1 Reply

OMG!!! ( 0 _ 0 ) wow... thanks!! what a silly mistake !! God for these things I love programming!! lol ─=≡Σ((( つ◕ل͜◕)つ well beginner stuff, haha lol. Thank you for such a kind answer, sorry for stealing some time with something so silly!! Cheers!!

Reply

Hahaha, my pleasure! I'm happy it was something simple ;)

Reply
Greta L. Avatar
Greta L. Avatar Greta L. | posted 3 years ago | edited

there Hi, in AutoGroupResourceMetadataFactory::getDefaultGroups, in return line, I think we should return $operationName in lowercase, since we are using "cheese:collection:post" (post is in lowercase). I'm not sure why there was no error in the video, but I got it. Maybe it's because I am using newer version both symfony and apiPlatform.

Reply

Hey Greta L.

Ohh, that's sound likely, ApiPlatform may have capitalized the $operationName. If it works for you after lowercasing it, then I think that's what happened. Thanks for reporting this.

Cheers!

Reply

Hey MolloKhan!

Hmmm. So I checked into this further to see if there might be some change in API Platform. By using the exact same code as this tutorial, $operationName inside getDefaultGroups() is always (already) in lowercase - like "get", "post" or "delete". I think upgraded to API Platform 2.5, and got the same result. It certainly doesn't hurt to lowercase $operationName - but as far as I can tell, it's already always lowercase. Are you seeing something different?

Cheers!

Reply

Hello,

You said:

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

But there is no code block on this page, or did I miss something?

Reply

Hey Rakodev

You are right. Code blocks for this page haven't been added yet. They will be tomorrow. Sorry for the delay!

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