Retrieve an Object with Desired Subresources with API Platform
Souleyman BEN HADJ BELGACEM9 min read
API Platform offers a lot of possibilities, once you learn to leverage its full capabilities, it will send your development skills to the next level, that’s what I learned when I discovered how to use data providers and extensions. API Platform beginners tend to use a lot of custom controllers by a lack of comprehension of it. API Platform is a really powerful framework that can manage a lot of things for you such as error management, and a lot of basic operations are optimized. Using too many Custom Controllers makes you lose these advantages. Moreover, you’re often re-developing something that already exists, and the way you develop it is almost always less optimized. The first time I used API Platform, it was on a company website with legacy code of previous developers, using Symfony and API Platform as backend technologies. The company wanted to add a blog dimension to its site. So, I had to allow editor users to write news in their language and translate it into several other languages used by the company. Readers can access these articles on the website’s home page, but only in their language. In order to manage these news I have different entities :
InternalNews
: news with common information like status (PUBLISHED, SCHEDULED…
), author, and a list ofInternalNewsTranslation
InternalNewsTranslation
: language-dependent information like title, content, and a languageLanguage
: language information (name, iso code)
In my case, no need to get every translation of the news I have to display, I only need the user’s language translation to avoid having to sort which language to show in front.
I didn’t want to use a Custom Controller because as I mentioned, API Platform services come with optimization for maintainability and speed.
Search filter
My first idea was to use a search filter. I knew that you could filter on entities’ properties with it but also on nested properties. When I created it in the InternalNews
Entity, filtered on the child property for the language and called the route with a query parameter matching the language (i.e. English), I was expecting to get only the English translation.
<?php
use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
#[ApiFilter(SearchFilter::class,
properties:["translations.language.isoCode" => "exact"]
)]
class InternalNews
Actually, I still got all my translations. After a lot of tests, I discovered something: search filter only checks the existence of the property. In this case, it only checks that the property value matches the searched value. In this case, it only checks that the news object possesses a property translation with the wanted language, if it exists it will return the full object. For example, if I make a request for this news with the filter translations.language.isoCode=ITA
, only News 2
will be retrieved because it’s the only one with an Italian translation. Nevertheless, all its translations will be retrieved
The documentation was not really clear so I didn’t expect this behavior. No matter, I can create my own custom filter.
Second solution: Custom Filters and Data providers
Custom filter
Vincent (a coworker) told me that I could use a data provider to retrieve the desired data, but I needed to create a custom filter in the first place to be able to collect the iso code linked to the user language. In order to do that, let’s create a new PHP class that implements FilterInterface
. FilterInterface
needs two methods: apply()
and getDescription()
. I will not focus on getDescription
, it’s only for documentation. The apply
function will be called on every route, before controllers or data managers (data provider, data persister or extensions). So the first thing I had to do is to check if the request has the query param I want to use, if not I can return and quit the function. If the request uses the wanted queryParam
I can access its value and put it in the context to use it a little bit further. Now I am able to get the user iso code. From now, I’ll need to process it to retrieve the associated translations. To do that, I’ll use a new concept : data providers.
<?php
namespace App\Portal\Filter;
use ApiPlatform\Core\Serializer\Filter\FilterInterface;
use Symfony\Component\HttpFoundation\Request;
class LanguageIsoCodeFilter implements FilterInterface
{
final public const LANGUAGE_ISO_CODE_FILTER_IN_CONTEXT = 'language_iso_code_filter';
/**
* @param array<mixed> $attributes
* @param array<mixed> $context
*/
public function apply(
Request $request,
bool $normalization,
array $attributes,
array &$context
): void
{
$languageIsoCode = $request->query->get("languageIsoCode");
if (!$languageIsoCode) {
return;
}
$context[self::LANGUAGE_ISO_CODE_FILTER_IN_CONTEXT] = $languageIsoCode;
}
/**
* @return array<mixed>
*/
public function getDescription(string $resourceClass): array
{
return [
'languageIsoCode' => [
'property' => null,
'type' => 'string',
'required' => false,
]
];
}
}
Data provider
Data provider is natively used to load data from data source. For example, when you use a default route like api/translation, API Platform will retrieve these data using a default data provider. In this case, I don’t want default values (which contain all translations), so I can implement my own data provider. To do this, I first created an InternalNewsCollectionDataProvider
class to implement CollectionDataProviderInterface
(or ContextAwareCollectionDataProviderInterface
if you’re using a version of API Platform below 3).
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
use App\Portal\Entity\InternalNews;
use App\Portal\Entity\InternalNewsTranslation;
use App\Portal\Entity\ThemeTranslation;
use App\Portal\Filter\LanguageIsoCodeFilter;
use App\Portal\Services\InternalNewsService;
use Doctrine\Common\Collections\ArrayCollection;
class InternalNewsCollectionDataProvider implements ContextAwareCollectionDataProviderInterface
{
public function __construct(
private readonly ContextAwareCollectionDataProviderInterface $decoratedDataProvider,
private readonly InternalNewsService $internalNewsService
)
{
}
CollectionDataProviderInterface
uses two methods: supports and getCollection
. By default, every data provider will be called on every call I will make. To prevent this and only use the right data provider on the right route I can use the supports method. The function getCollection
will only apply if the function supports return true. In this case, I want to use my data provider if the route called operation name is get_published_internal_news
(which is the name of the operation I am working on) and if the ressource class is an InternalNews
.
/**
* @param array<mixed> $context
*/
public function supports(
string $resourceClass,
string $operationName = null,
array $context = []): bool
{
return InternalNews::class === $resourceClass &&
$operationName === 'get_published_internal_news'
}
Now that my data provider only applies on the right route I can focus on the getCollection
function. First, let’s inject another CollectionDataProviderInterface
into the construct of ours. We will use it in getCollection
to get the default data provider value (with all the translations). Next step, I will retrieve the language value from the context, that we previously added with our custom filter. I just need to browse all the news provided by the default data provider and filter on the correct language and return it. From this point, I was able to retrieve all published internal news with only the request language.
/**
* @param array<mixed> $context
*
* @return iterable<int, InternalNews>
*
* @throws ResourceClassNotSupportedException
*/
public function getCollection(
string $resourceClass,
string $operationName = null,
array $context = []
): iterable
{
/** @var iterable<int, InternalNews> $internalNewsCollection */
$internalNewsCollection = $this->decoratedDataProvider->getCollection(
$resourceClass,
$operationName,
$context
);
/** @var string | false $languageIsoCodeToFilterOn */
$languageIsoCodeToFilterOn = $context[LanguageIsoCodeFilter::LANGUAGE_ISO_CODE_FILTER_IN_CONTEXT] ?? false;
if (false !== $languageIsoCodeToFilterOn ) {
foreach ($internalNewsCollection as $internalNews) {
$this->internalNewsService->filterTranslationsForLanguage($internalNews, $languageIsoCodeToFilterOn);
}
}
return $internalNewsCollection;
}
public function filterTranslationsForLanguage(
InternalNews $internalNews,
string $languageIsoCode
): void
{
$internalNews->setTranslations(
new ArrayCollection(
$internalNews->getTranslations()->filter(
fn(InternalNewsTranslation $translation = null) =>
$translation && $languageIsoCode === $translation->getLanguage()->getCodeIso()
)->getValues()
)
);
foreach ($internalNews->getThemes() as $theme) {
$theme->setTranslations(
new ArrayCollection(
$theme->getTranslations()->filter(
fn(ThemeTranslation $translation = null) =>
$translation && $languageIsoCode === $translation->getLanguage()->getCodeIso()
)->getValues()
)
);
}
}
Expert mode : Extension
I used the previous solution for a quite long time but I improved it for performance reasons. The issue of data provider solution was that I put a lot of load on the PHP part that can create slowness. In this case, I load all the data from the database and then I filter to only keep the interesting values. The idea I had to improve performance was to retrieve only the needed value from the database, so I don’t have to filter in back.
Extensions allows developers to modify the SQL request made by Doctrine in order to specify it for example. In our case, I wanted to specify the way the join between tables InternalNews
and InternalNewsTranslation
tables was done in order to get only selected translation.
In practice, I deleted our InternalNewsCollectionDataProvider
and created a FilteredOnLanguageIsoCodeExtension
class. This class will implement QueryCollectionExtensionInterface
(or ContextAwareQueryCollectionExtensionInterface
if you’re using an API Platform version below 3). Only one method is necessary to use QueryCollectionExtensionInterface
: applyToCollection
. Extensions will apply on every call you will do on your database and it doesn’t have a support method, so I firstly have to check that I call the expected route with a good object type. Then, I can get the value of the language I want to use from context and actually use it to add condition in our query builder (in addWhere
function in our example). This way, I can easily get InternalNews only with desired translations with better performance than with a data provider.
<?php
namespace App\Portal\Extension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\ContextAwareQueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Portal\Entity\InternalNews;
use App\Portal\Entity\Language;
use App\Portal\Filter\LanguageIsoCodeFilter;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
class FilteredOnLanguageIsoCodeExtension implements ContextAwareQueryCollectionExtensionInterface
{
/**
* @param array<mixed> $context
*/
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?string $operationName = null,
array $context = []
): void
{
/** @var string | false $languageIsoCodeToFilterOn */
$languageIsoCodeToFilterOn = $context[LanguageIsoCodeFilter::LANGUAGE_ISO_CODE_FILTER_IN_CONTEXT] ?? false;
if (false !== $languageIsoCodeToFilterOn && in_array(
$operationName,
[
'get_published_internal_news',
])) {
$this->addWhere($queryBuilder, $resourceClass, $languageIsoCodeToFilterOn);
}
}
private function addWhere(
QueryBuilder $queryBuilder,
string $resourceClass,
string $languageIsoCodeToFilterOn
): void
{
if (InternalNews::class !== $resourceClass) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
/** @var Language $selectedLanguage */
$selectedLanguage = $queryBuilder->getEntityManager()->getRepository(Language::class)
->findOneBy(["codeIso" => $languageIsoCodeToFilterOn]);
$internalNewsTranslations = sprintf('%s.translations', $rootAlias);
$queryBuilder
->join($internalNewsTranslations, 'int', Join::WITH, 'int.language = :newsSelectedLanguage')
->setParameter('newsSelectedLanguage', $selectedLanguage)
}
}
The performance improvement comes precisely from these lines :
$queryBuilder
->join($internalNewsTranslations, 'int', Join::WITH, 'int.language = :newsSelectedLanguage')
->setParameter('newsSelectedLanguage', $selectedLanguage)
Doctrine allows me to add a condition when performing the tables join with the JOIN::WITH
parameter. In this case, when joining tables, Doctrine will only keep InternalNewsTranslations
with the requested language. Without this JOIN::WITH
, I should have to join tables and then filter with an andWhere
to keep InternalNewsTranslations
with the requested language which performs less well.
Replacing the data provider by an extension using the join
method with a JOIN::WITH
parameter made the operation six times faster!
Conclusion
I finally achieved retrieving news only with its desired translations using a custom filter and an extension. This experience made me aware of the power and the utility of API Platform and made me quit using custom controller. A lot of other tools to manipulate data are provided by API Platform such as data persister, voters or normalization context. Even if API Platform documentation is hard to understand, it’s worth to deep dive into it to find which tool will be the most optimal to create or optimize a wanted behavior, like I did to improve performances using an extension instead of a data provider