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.1
  • Symfony 5.4.15
  • 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 Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * 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 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:

The OpenApi interface with the stats endoint

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'])]
#[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;

#[ApiResource(
    shortName: 'Article',
    operations: [
        new GetCollection(
            uriTemplate: '/articles/excerpt',
            provider: ArticleExcerptProvider::class,
            extraProperties: [
                'entity' => Article::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 function Symfony\Component\String\u;

use Symfony\Contracts\Translation\TranslatorInterface;

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 class ArticleExcerptProvider implements ProviderInterface
{
    /**
     * @param ProviderInterface<Article> $collectionProvider
     */
    public function __construct(
        readonly private ProviderInterface $collectionProvider,
        readonly private ArticleExcerptDataTransformer $articleExcerptDataTransformer,
        readonly private ResourceMetadataCollectionFactoryInterface $collectionFactory,
    ) {
    }

    /**
     * @param array<mixed> $uriVariables
     * @param array<mixed> $context
     *
     * @return array<ArticleExcerpt>
     */
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
    {
        $collection = $this->collectionFactory->create($operation->getExtraProperties()['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. 😊

  Read the doc  More on Tweeter

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 Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil