Replacing manual API endpoints with API Platform 3 in a Symfony application
Published on 2022-11-19 • Modified on 2022-12-08
In this post, we rewrite a previous article that used API Platform 2.6 to use the new major version of API Platform 3. Let's go! 😎
Prerequisite
I assume you have basic Symfony knowledge and know what a REST API is.
Configuration
- PHP 8.4
- Symfony 6.4.16
- API Platform 3.0.x (or 2.7.x)
Introduction
In a previous article, we saw how to document and create a new endpoint with API Platform 2.6. It worked well, but we had to do some hacks, like extending the swagger documentation. Let's see how it can be done now with the shiny new major version 3 of API Platform.
Goal
This article will be almost identical to the previous one but using API Platform 3.
Replacing the legacy endpoint
Let's look at one of the manual endpoints I have on the blog. It's a simple controller with a unique action returning a JsonResponse
, thanks to the json()
helper. It returns an array with two keys, the number of blog posts and snippets:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\ArticleRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Simple stats endpoint for Strangebuzz.fr.
*/
final class ApiController extends AbstractController
{
#[Route(path: '/stats', name: 'stats')]
public function stats(ArticleRepository $articleRepository): Response
{
return $this->json($articleRepository->getArticlesCount());
}
It returns this:
{"posts":20,"snippets":98}
In the previous article, we just documented this legacy endpoint. Now, let's see how to migrate it with API Platform 3. The first step is to create a resource. As this endpoint provides statistics, we can name it StatResource
. Here is it:
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\StatProvider;
#[ApiResource(
shortName: 'Stat',
operations: [
new Get(
uriTemplate: '/stats',
openapiContext: ['summary' => self::DESCRIPTION],
description: self::DESCRIPTION,
provider: StatProvider::class
),
]
)]
final class StatResource
{
final public const string DESCRIPTION = 'Retrieve some stats about blog posts and snippets.';
// total number of blog posts
public int $posts;
// total number of snippets
public int $snippets;
}
Some explanations, this class is a simple POPO, which exposes two properties, $posts
and $snippets
. They correspond to the output of the legacy endpoint.
We add the ApiResource
attribute with a GET operation. The uriTemplate
parameter corresponds to the route path of the legacy controller. Then we associate a provider to this operation. We have less magic with API Platform 3. In this case, we directly know which service provides the data for this resource. Let's see this provider:
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\StatResource;
use App\Repository\ArticleRepository;
/**
* @implements ProviderInterface<StatResource>.
*/
final class StatProvider implements ProviderInterface
{
public function __construct(
private readonly ArticleRepository $articleRepository,
) {
}
/**
* @param array<mixed> $uriVariables
* @param array<mixed> $context
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$statResource = new StatResource();
['posts' => $statResource->posts, 'snippets' => $statResource->snippets] = $this->articleRepository->getArticlesCount();
return $statResource;
}
}
This service implements the generic ProviderInterface
, which exposes a unique function provide()
. As it's a GET operation on the StatResource
, the provider's goal is to return a StatResource
object, that's all. We get the statistics from the Doctrine ArticleRepository
. Then we map the returned values to the two properties of the resource, and we are good. Let's open the swagger interface:
No need to hack the documentation this time. Everything is already OK, we have the endpoint, and it returns the statistics in the JSON-LD format. That was different for the legacy endpoint, where only the two posts
and snippets
keys were returned (simple JSON). Also, note that the endpoint is now prefixed with /api
as it inherits the API Platform resources global prefix. It's much cleaner now, isn't it? 😊
Now, let's see how to convert a legacy 2.6 DataTransformer
with API Platform 3.
Getting an article list excerpt
On my professional portfolio website, I need to retrieve the list of the last posts' summaries.
First, I have activated APIP on the Article
entity thanks to the #ApiResource
attribute:
/**
* An article, such as a news article or piece of investigative report.
* Newspapers and magazines have articles of many different types and this is intended to cover them all.
*/
#[ApiResource(
types: ['https://schema.org/Article', 'https://schema.org/TechArticle'],
operations: [
new GetCollection(),
],
normalizationContext: ['groups' => [self::GROUP_DEFAULT]]
)]
#[ApiFilter(OrderFilter::class, properties: ['id' => 'DESC'])]
#[ApiFilter(SearchFilter::class, properties: ['id' => 'exact', 'type' => 'exact', 'inLanguage' => 'partial', 'title' => 'partial'])]
#[ApiFilter(BooleanFilter::class, properties: ['active'])]
#[ApiFilter(ExistsFilter::class, properties: ['stackoverflowUrl'])]
#[ORM\EntityListeners([ArticleListener::class])]
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article extends BaseEntity
Doctrine manages this resource, and I have added several filters. Now let's declare a new resource ArticleExcerpt
that will handle the new endpoint. Here it is:
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Entity\Article;
use App\Enum\ArticleType;
use App\State\ArticleExcerptProvider;
use App\State\ArticleSuggestProvider;
#[ApiResource(
shortName: 'Article',
operations: [
new GetCollection(
uriTemplate: '/articles/excerpt',
provider: ArticleExcerptProvider::class,
extraProperties: [
'entity' => Article::class,
],
),
new GetCollection(
// _api_/articles/suggest_get_collection
uriTemplate: '/articles/suggest',
formats: ['json'], // force json for the autocomplete widget
provider: ArticleSuggestProvider::class
),
],
)]
final class ArticleExcerpt
{
public int $id;
public ArticleType $type;
public string $title;
public string $summary;
public string $linkHref;
public string $published;
public string $updated;
}
Some explanations: we modify the shortName
parameter, so this endpoint appears with the other standards Article
ones. The uriTemplate
parameter defines the custom operation path. Finally, we use an extra property entity
; we will use it to link this resource to the article
As I have to do some processing to get the correct output (like generating the URL), I created a DTO, which a DataTransformer
processes. This service is similar to the legacy DataTransformer
except it doesn't implement the DataTransformerInterface
.
<?php
declare(strict_types=1);
namespace App\DataTransformer;
use App\ApiResource\ArticleExcerpt;
use App\Entity\Article;
use App\Twig\Extension\SlugExtension;
use Symfony\Bridge\Twig\Extension\RoutingExtension;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\String\u;
final class ArticleExcerptDataTransformer
{
public function __construct(
private readonly SlugExtension $slugExtension,
private readonly TranslatorInterface $translator,
private readonly RoutingExtension $routingExtension,
) {
}
public function transform(Article $value): ArticleExcerpt
{
[$translationDomain, $route, $slug] = $this->getContext($value);
$output = new ArticleExcerpt();
$output->id = (int) $value->getId();
$output->type = $value->getType();
$output->title = trim($this->translator->trans('title_'.$value->getId(), [], $translationDomain));
$output->summary = trim($this->translator->trans('headline_'.$value->getId(), [], $translationDomain));
$output->linkHref = u($slug)->trim()->isEmpty() ? '' : $this->routingExtension->getUrl($route, ['slug' => $slug]);
$output->published = $this->getDateAsAtom($value->getDatePublished());
$output->updated = $this->getDateAsAtom($value->getDateModified());
return $output;
}
private function getDateAsAtom(?\DateTimeInterface $dateTime): string
{
return $dateTime !== null ? $dateTime->format(\DateTimeInterface::ATOM) : '';
}
/**
* @return array<int,string>
*/
private function getContext(Article $article): array
{
if ($article->isArticle()) {
$translationDomain = 'blog';
$route = 'blog_show';
$slug = $this->slugExtension->getArticleSlug((string) $article->getSlug());
} else {
$translationDomain = 'snippet';
$route = 'snippet_show';
$slug = $this->slugExtension->getSnippetSlug((string) $article->getSlug());
}
return [$translationDomain, $route, $slug];
}
}
Don't take care of the specific code related to the blog. The important thing here is the transform()
function; it takes an Article
entity and converts it into an ArticleExerpt
DTO.
In API Platform 3, we now have providers to retrieve resources. There are generic ones for Doctrine ORM: CollectionProvider
and ItemProvider
. Here, we need a custom provider (the one we declared in the ArticleExerpt
ressource), but as we retrieve Articles
from the database, we can use the default CollectionProvider
service. This provider is straightforward; it retrieves articles as would do the generic one, and then it transforms the Article
collection into an ArticleExcerpt
array (we could also return a Doctrine collection).
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ArticleExcerpt;
use App\DataTransformer\ArticleExcerptDataTransformer;
use App\Entity\Article;
/**
* @implements ProviderInterface<ArticleExcerpt>
*/
final readonly class ArticleExcerptProvider implements ProviderInterface
{
/**
* @param ProviderInterface<Article> $collectionProvider
*/
public function __construct(
private ProviderInterface $collectionProvider,
private ArticleExcerptDataTransformer $articleExcerptDataTransformer,
private ResourceMetadataCollectionFactoryInterface $collectionFactory,
) {
}
/**
* @param array<mixed> $uriVariables
* @param array<mixed> $context
*
* @return array<ArticleExcerpt>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
/** @var array{entity: string} $extraProperties */
$extraProperties = $operation->getExtraProperties();
$collection = $this->collectionFactory->create($extraProperties['entity'])->getOperation(forceCollection: true);
/** @var Paginator $paginator */
$paginator = $this->collectionProvider->provide($collection, $uriVariables, $context);
/** @var array<Article> $articles */
$articles = iterator_to_array($paginator, false);
return array_map($this->articleExcerptDataTransformer->transform(...), $articles);
}
}
That's it; now we can try with the help of the OpenApi interface, CURL or Postwoman.
curl -X GET "https://www.strangebuzz.com/api/articles/excerpt?order%5Bid%5D=desc&type=blog_post&active=true" -H "accept: application/ld+json"
We get the following output (with only one article, and I removed the hydra:search
section):
{
"@context": "/api/contexts/Article",
"@id": "/api/articles/excerpt",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/api/articles/232",
"@type": "Article",
"id": 232,
"type": "blog_post",
"title": "Replacing manual API endpoints with API Platform 3 in a Symfony application",
"summary": "In this post, we rewrite a previous article that used API Platform 2.6 to use the new major version of API Platform 3.",
"linkHref": "https://127.0.0.1:8000/en/blog/replacing-manual-api-endpoints-with-api-platform-3-in-a-symfony-application",
"published": "2022-11-19T00:00:00+01:00",
"updated": "2022-11-19T00:00:00+01:00"
},
],
"hydra:totalItems": 10,
"hydra:view": {
"@id": "/api/articles/excerpt?order%5Bid%5D=desc&type=blog_post&active=true",
"@type": "hydra:PartialCollectionView"
}
}
Now, I have to query this new endpoint with the proper parameters in my portfolio project. We can use the Symfony HTTP client:
$endPoint = sprintf('%s/api/articles/excerpt?type=blog_post&active=1&order[id]=desc', $this->dotcomUrl);
$headers = [
'accept' => 'application/ld+json',
];
$request = $this->httpClient->request('GET', $endPoint, [
'verify_peer' => $this->appEnv !== 'dev',
'headers' => $headers
]);
$json = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$articles = $json['hydra:member'] ?? [];
First, we build the endpoint URL with suitable parameters. We retrieve active articles (active=1
) of the blog post type (type=blog_post
), and we order them by descending ID (order[id]=desc
) to have the most recent ones first.
Handling of the locale
This section is identical to the previous article. You can check it out here.
Discover API Platform 3
Still waiting to know API Platform 3? Check out these slides from Kevin:
You can also access all recorded talks during the API Platform con 2022.
Thanks to Antoine Bluchet (Soyuka) for helping me to optimize this blog post. A PR should come to natively allow us to use an extra property to map a resource to another, as we did here.
Conclusion
We now use API Platform 3, there is less magic involved and we didn't have to hack the swagger documentation this time. In the resources, we see the providers for a given custom operation. These providers are straightforward to create as a unique provide()
function must be implemented.
😉
That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊
They gave feedback and helped me to fix errors and typos in this article; many thanks to Laurent, Soyuka 👍
Call to action
Did you like this post? You can help me back in several ways: (use the "reply" link on the right to comment or to contact me )
- Report any error/typo.
- Report something that could be improved.
- Like and repost!
- Follow me on Bluesky 🦋
- Subscribe to the RSS feed.
- Click on the More on Stackoverflow buttons to make me win "Announcer" badges 🏅.
Thank you for reading! And see you soon on Strangebuzz! 😉