Replacing manual API endpoints by API Platform in a Symfony application

Published on 2020-12-03 • Modified on 2021-09-03

In this post, we will see how API Platform can help us to document and migrate manual endpoints in a Symfony application. We will see concrete examples related to this blog application. Let's go! 😎

» Published in "A week of Symfony 727" (30 November - 6 December 2020).

A new article has been published for API Platform 3. You can find it here.

Prerequisite

I will assume you have a basic knowledge of Symfony and that you know what a REST API is.

Configuration

  • PHP 8.3
  • Symfony 6.4.6
  • API Platform 2.6.6

Introduction

I am playing/working with API Platform for a few weeks. I decided to use it on this blog project. Let's see how it went and what things I have learned and discovered.

Goal

The goal is to convert (or not) two manual API endpoints I have on this blog by using API platform.

Documenting a "legacy" endpoint

Let's have a look at one of the manual endpoint 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 (you can also check the output here /stats):

{"posts":20,"snippets":98}

Should we convert the endpoint to API PLatform? We could do a DTO with two properties and handle it via APIP with an arbitrary identifier, the URL could look like: /api/stats/1acfcce8-10de-47a1-8985-d9bff214085d. But does it worth doing it? In fact, no, here we won't need APIP features like pagination, filtering, etc. So it's better to keep it like this. But APIP can still be useful in this situation. We will use it do make this endpoint appear in the OpenApi interface.

Extending the OpenApi documentation

To do so, we need to decorate the api_platform.openapi.factory like the following (services.yaml):


    # API Platform (I have migrated to API PLatform 2.7 / 3.0, check the new blog post)
    # App\OpenApi\OpenApiFactory:

And here is the decorator:

<?php

declare(strict_types=1);

namespace App\OpenApi;

use OpenApiFactoryInterface;
use ApiPlatform\Core\OpenApi\Model\Contact;
use ApiPlatform\Core\OpenApi\Model\Info;
use ApiPlatform\Core\OpenApi\Model\Operation;
use ApiPlatform\Core\OpenApi\Model\PathItem;
use ApiPlatform\Core\OpenApi\OpenApi;
use Symfony\Component\HttpFoundation\Response;

final class OpenApiFactory implements OpenApiFactoryInterface
{
    private OpenApiFactoryInterface $decorated;
    private string $appVersion;

    public function __construct(OpenApiFactoryInterface $decorated, string $appVersion)
    {
        $this->decorated = $decorated;
        $this->appVersion = $appVersion;
    }

    /**
     * @param array<mixed> $context
     */
    public function __invoke(array $context = []): OpenApi
    {
        $openApi = ($this->decorated)($context);
        $openApi
            ->getPaths()
            ->addPath('/stats', new PathItem(null, null, null, new Operation(
                'get',
                ['Stats'],
                [
                    Response::HTTP_OK => [
                        'content' => [
                            'application/json' => [
                                'schema' => [
                                    'type' => 'object',
                                    'properties' => [
                                        'posts' => [
                                            'type' => 'integer',
                                            'example' => 20,
                                        ],
                                        'snippets' => [
                                            'type' => 'integer',
                                            'example' => 97,
                                        ],
                                    ],
                                ],
                            ],
                        ],
                    ],
                ],
                'Retrieves the number of blog posts and snippets.'
            )
            ));

        return $openApi->withInfo(new Info(
            'The Strangebuzz API 🐝/🕸',
            $this->appVersion,
            'This is the API Platform demo used in the various blog posts of this website.',
            'https://www.strangebuzz.com/en/gdpr',
            new Contact('Vernet Loïc', 'https://www.strangebuzz.com/en/about')
        ));
    }
}

Now, this endpoint is available in the OpenApi interface:

The OpenApi interface with the stats endoint documented

And we can try it:

Output of the stats endpoint in the OpenApi interface

Now, let's see a case where we will use the APIP features.

Getting an article list

On my professional portfolio website, I need to retrieve the list of the last posts summary. As this blog has a list page, all I have to do is to handle a new JSON format for the list action. So, here is what I did at first:

/**
 * @Route("/", name="list", defaults={"_format": "html", "tag": ""})
 * ...
 * @Route("/rss.json", name="list_json", defaults={"_format": "json", "tag": ""})
 * ...
 */

I had a list_json route which was responsible for the JSON format. I had a specific template where I made a loop on the articles to generate the JSON file; this was quite ugly! 🤮 But it worked well as Twig handles translations and I could also use specific extensions. Now let's see how to do this the right way with APIP:

First, I have activated APIP on the Article entity thanks to the @ApiResource annotation:

use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

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

As I have to do some processing to get the correct output (in fact like in the Twig template). I created a DTO which is processed by a DataTransformer, here it is:

<?php

declare(strict_types=1);

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\ArticleExcerpt;
use App\Entity\Article;
use App\Twig\Extension\SlugExtension;
use Symfony\Bridge\Twig\Extension\RoutingExtension;
use Symfony\Bridge\Twig\Extension\TranslationExtension;

use function Symfony\Component\String\u;

final class ArticleExcerptDataTransformer implements DataTransformerInterface
{
    public function __construct(
        private SlugExtension $slugExtension,
        private TranslationExtension $translationExtension,
        private RoutingExtension $routingExtension)
    {
    }

    /**
     * @param array<mixed> $context
     */
    public function transform($object, string $to, array $context = []): ArticleExcerpt
    {
        if (!$object instanceof Article) {
            throw new \LogicException('This data transformer can only process an "Article" entity.');
        }
        [$translationDomain, $route, $slug] = $this->getContext($object);
        $output = new ArticleExcerpt();
        $output->id = (int) $object->getId();
        $output->title = trim($this->translationExtension->trans('title_'.$object->getId(), [], $translationDomain));
        $output->summary = trim($this->translationExtension->trans('headline_'.$object->getId(), [], $translationDomain));
        $output->linkHref = u($slug)->trim()->isEmpty() ? '' : $this->routingExtension->getUrl($route, ['slug' => $slug]);
        $output->published = $this->getDateAsAtom($object->getDatePublished());
        $output->updated = $this->getDateAsAtom($object->getDateModified());

        return $output;
    }

    /**
     * @param object|array<mixed> $data
     * @param array<mixed>        $context
     */
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return ArticleExcerpt::class === $to && $data instanceof Article;
    }

    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];
    }
}
Click here to see the ArticleExcerpt DTO.
<?php

declare(strict_types=1);

namespace App\Dto;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Entity\Article;
use Symfony\Component\Serializer\Annotation\Groups;

#[ApiResource]
final class ArticleExcerpt
{
    #[ApiProperty(identifier: true)]
    public int $id;

    #[Groups(groups: [Article::GROUP_SBFR])]
    public string $title;

    #[Groups(groups: [Article::GROUP_SBFR])]
    public string $summary;

    #[Groups(groups: [Article::GROUP_SBFR])]
    public string $linkHref;

    #[Groups(groups: [Article::GROUP_SBFR])]
    public string $published;

    #[Groups(groups: [Article::GROUP_SBFR])]
    public string $updated;
}

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. That's it, now we can try with the help of the OpenApi interface, CURL, Postman or Postwoman.

curl -X GET "https://www.strangebuzz.com/api/articles?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",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/api/articles/120",
      "title": "Replacing manual API endpoints by API Platform in a Symfony application",
      "summary": "In this post, we will see how API Platform can help us to document and migrate manual endpoints in a Symfony application. We will see concrete examples related to this blog application.",
      "linkHref": "https://www.strangebuzz.com/en/blog/replacing-manual-api-endpoints-by-api-platform-in-a-symfony-application",
      "published": "2020-11-28T00:00:00+01:00",
      "updated": "2020-11-28T00:00:00+01:00"
    }
  ],
  "hydra:totalItems": 20,
  "hydra:view": {
    "@id": "/api/articles?order%5Bid%5D=desc&type=blog_post&active=true",
    "@type": "hydra:PartialCollectionView"
  }
}

That's it; now, in my portfolio project, I have to query this new endpoint with the right parameters. We can use the Symfony HTTP client:

$endPoint = sprintf('%s/api/articles?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 the suitable parameters. We retrieve active articles (active=1) of the blog post type (type=blog_post), and we order them by ID desc (order[id]=desc) to have the most recent ones first.

Handling of the locale

But there is a small problem, how can we do to retrieve the French version of the articles? Before, it was handled by Symfony as the route already included the locale; its path was /{_locale}/blog/rss.json. But APIP standard endpoints don't include the locale. Let's do things the right way, we pass a new Accept-Language header:

$headers = [
    'accept'          => 'application/ld+json',
    'Accept-Language' => $locale,
];

This Accept-Language can either contain fr_FR or en_EN, the CURL call now becomes:

curl -X GET "https://www.strangebuzz.com/api/articles?order%5Bid%5D=desc&type=blog_post&active=true&page=1" -H  "accept: application/ld+json" -H "Accept-Language: fr_FR"

But it doesn't work as this header is not automatically handled by Symfony. It fact, it can process this information; indeed, I already use this feature in the action that addresses the root path (/) to redirect the user to the most adapted locale:


    #[Route(path: '/', name: 'root')]
    public function root(Request $request): Response
    {
        $locale = $request->getPreferredLanguage($this->getParameter('activated_locales'));

        return $this->redirectToRoute('homepage', ['_locale' => $locale]);

The request::getPreferredLanguage() function returns which language suits the better the client browser depending on a list of available locales. In my case, it returns "fr" if it's the browser preferred language; otherwise, it returns "en".

So, for the APIP call, we will also use this practical getPreferredLanguage() function. We have to create a subscriber that listens to the KernelEvents::REQUEST event:

<?php

declare(strict_types=1);

namespace App\Subscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\EventListener\LocaleListener;
use Symfony\Component\HttpKernel\KernelEvents;

use function Symfony\Component\String\u;

final class LocaleSubscriber implements EventSubscriberInterface
{
    /**
     * @var array<int, string>
     */
    private array $activatedLocales;

    /**
     * @param array<int, string> $activatedLocales
     */
    public function __construct(array $activatedLocales)
    {
        $this->activatedLocales = $activatedLocales;
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        // Only try the change the locale if the accept language header is set
        if (!$request->headers->has('Accept-Language')) {
            return;
        }

        $language = u($request->getPreferredLanguage($this->activatedLocales))->trim();
        if (!$language->isEmpty()) {
            $request->setLocale($language->toString());
        }
    }

    /**
     * @see LocaleListener
     */
    public static function getSubscribedEvents(): array
    {
        return [
            // must be registered before (i.e. with a higher priority than) the
            // default LocaleListener which has a 16 priority.
            KernelEvents::REQUEST => [['onKernelRequest', 20]],
        ];
    }
}
Click here to see the Symfony LocaleListener.
<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\HttpKernel\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\KernelEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RequestContextAwareInterface;

/**
 * Initializes the locale based on the current request.
 *
 * @author Fabien Potencier <fabien@symfony.com>
 *
 * @final
 */
class LocaleListener implements EventSubscriberInterface
{
    private ?RequestContextAwareInterface $router;
    private string $defaultLocale;
    private RequestStack $requestStack;
    private bool $useAcceptLanguageHeader;
    private array $enabledLocales;

    public function __construct(RequestStack $requestStack, string $defaultLocale = 'en', ?RequestContextAwareInterface $router = null, bool $useAcceptLanguageHeader = false, array $enabledLocales = [])
    {
        $this->defaultLocale = $defaultLocale;
        $this->requestStack = $requestStack;
        $this->router = $router;
        $this->useAcceptLanguageHeader = $useAcceptLanguageHeader;
        $this->enabledLocales = $enabledLocales;
    }

    public function setDefaultLocale(KernelEvent $event): void
    {
        $event->getRequest()->setDefaultLocale($this->defaultLocale);
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        $this->setLocale($request);
        $this->setRouterContext($request);
    }

    public function onKernelFinishRequest(FinishRequestEvent $event): void
    {
        if (null !== $parentRequest = $this->requestStack->getParentRequest()) {
            $this->setRouterContext($parentRequest);
        }
    }

    private function setLocale(Request $request): void
    {
        if ($locale = $request->attributes->get('_locale')) {
            $request->setLocale($locale);
        } elseif ($this->useAcceptLanguageHeader) {
            if ($request->getLanguages() && $preferredLanguage = $request->getPreferredLanguage($this->enabledLocales)) {
                $request->setLocale($preferredLanguage);
            }
            $request->attributes->set('_vary_by_language', true);
        }
    }

    private function setRouterContext(Request $request): void
    {
        $this->router?->getContext()->setParameter('_locale', $request->getLocale());
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => [
                ['setDefaultLocale', 100],
                // must be registered after the Router to have access to the _locale
                ['onKernelRequest', 16],
            ],
            KernelEvents::FINISH_REQUEST => [['onKernelFinishRequest', 0]],
        ];
    }
}

In this listener, we check if the request has an Accept-Language header. If it is the case, we get the preferred language as we did before. Then we set the locale; the $language variable will always be defined as if there aren't matches, the default (the first available) locale will be returned. The function handles the validity of this value; if it's incorrect, it ignores it and returns the default locale. It also handles localization like "fr_CA" which matches "fr". Now it works, the content is correctly translated depending on the Accept-Language header. We can test with the following commands:

curl -X GET "https://www.strangebuzz.com/api/articles?order%5Bid%5D=desc&type=blog_post&active=true" -H "Accept-Language: en_US" -H "accept: application/ld+json"
curl -X GET "https://www.strangebuzz.com/api/articles?order%5Bid%5D=desc&type=blog_post&active=true" -H "Accept-Language: fr_CA" -H "accept: application/ld+json" 

API Component

Conclusion

We saw two concrete cases where APIP is useful. Of course, these are two simple cases, but it was to show the potential API Platform has; it's scalable, flexible and can be adapted to your needs whatever the complexity is. I hope it will give you the desire to test it if you don't know it already. There's a good chance that you won't get disappointed! 😉

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  What's new in API Platform 2.6 ?

PS: We are currently updating the API Platform demo application to add more "real world" examples. These are more tangible than what the documentation provides and are easy to copy/paste. Then, they are ready to be adapted to your projects. Of course, they respect API Platform best practices.

They gave feedback and helped me to fix errors and typos in this article; many thanks to Bruce 👍

  Work with me!


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