How to use Extensions in API Platform properly?

How to use extensions in api platform properly?

Imagine you’re working on this big application which involves an API and a React frontend. You decide on using API Platform for your REST API because of its ease of use. Because the users security is a great concern you want to find an easy way to make sure a user can only access their own data. Ofcourse you can create custom actions where you manually filter the returned data from the database. But this can be really tedious if you have a lot of database entities. To combat this, we can leverage extensions in API Platform.

Extensions in API Platform

Introducing the extensions of API Platform. These classes are called for every request that comes in. These extension classes are able to change the DQL generated by the platform. To create an extension you need to create a class implementing ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface and / or ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface.

If you create a class implemting one or both of these interfaces they will automatically be picked up by the symfony dependency injection container. If you don’t use auto wiring you will need to tag the classes as follows:

services:    
    App\Infrastructure\Api\Extension\ExampleExtension:
        tags:
            - { name: api_platform.doctrine.orm.query_extension.collection }
            - { name: api_platform.doctrine.orm.query_extension.item }Code language: CSS (css)

User filtering using API Platform’s Extensions

In our large application we want to make sure users can only access their own data. To do this efficiently we can harness the power of extensions. Let’s say for example we have a Profile entity and an Address entity which are linked to a user. Both of these entities have a property called user which contains the user it belongs to. To make sure we don’t fetch the data of the wrong user we can use an extension:

namespace App\Infrastructure\Api\Extension;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Address;
use App\Entity\Profile;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;

/**
 * This extension makes sure normal users can only access their own Addresses and PRofiles
 */
final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    /**
     * @param Security $security
     */
    public function __construct(private Security $security) {}

    /**
     * @param QueryBuilder $queryBuilder
     * @param QueryNameGeneratorInterface $queryNameGenerator
     * @param string $resourceClass
     * @param string|null $operationName
     */
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
    {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    /**
     * @param QueryBuilder $queryBuilder
     * @param QueryNameGeneratorInterface $queryNameGenerator
     * @param string $resourceClass
     * @param array $identifiers
     * @param string|null $operationName
     * @param array $context
     */
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = []): void
    {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    /**
     * @param QueryBuilder $queryBuilder
     * @param string $resourceClass
     *
     */
    private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
    {
        if (Address::class !== $resourceClass
            || Profile::class !== $resourceClass
            || $this->security->isGranted(User::ROLE_ADMIN)
            || null === $user = $this->security->getUser()
        ) {
            return;
        }

        $rootAlias = $queryBuilder->getRootAliases()[0];
        $queryBuilder->andWhere(sprintf('%s.user = :current_user', $rootAlias));

        /** @var User $user */
        $queryBuilder->setParameter('current_user', $user->getId());
    }
}Code language: PHP (php)

We inject the Symfony\Component\Security\Core\Security class so we are able to get the current user of the request. We need to implement the applyToCollection and the applyToItem functions for the two extension interfaces. These methods are called by the API Platform when we do a GET collection request or a GET item request respectively. Because we want to perform the same action in both cases we created the method addWhere which receives the QueryBuilder and the resource class.

Because we only want to change the query for certain cases we check if the resourceClass is supported by the extension or if the user has the role ROLE_ADMIN. If the resourceClass is not supported or the user is an admin we simple exit the function, since we don’t need to add the filter. If it is a supported class and the user is not an admin we use the query builder to add a where clause making sure we only fetch data for the current user.

Protip

If you find yourself adding alot of resourceClass checks to your if statement, try using an interface. You can make an interface called UserDataInterface and change the if statement to check if the resourceClass is an implementation of this interface. For example:

namespace App\Domain\Interface;

use App\Entity\User;

interface UserDataInterface 
{
    public function getUser(): User;
    // ... More methods if needed
}Code language: PHP (php)

You can now change your if-statement to the following:

// ... removed for brevity
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
    if (is_subclass_of($resourceClass, UserDataInterface::class)
        || $this->security->isGranted(User::ROLE_ADMIN)
        || null === $user = $this->security->getUser()
    ) {
        return;
    }

    // ... filter code
}Code language: PHP (php)

It’s a small change, with a large impact. The function is_subclass_of checks if the class (first parameter can be an instance or a string) is a subclass of another class, or interface (the second parameter). So now this extension will add the filter for every class we mark with implements UserDataInterface.

Do you have any useful tips or tricks using API Platform? Leave them in the comments!

Leave a Reply

Your email address will not be published. Required fields are marked *