Remplacer des points d'accès d'API manuels par API Platform dans une application Symfony

Publié le 03/12/2020 • Actualisé le 03/09/2021

Dans cet article nous allons voir comment API Platform peut nous aider à migrer et à documenter des points d'accès d'API manuels d'une application Symfony. Nous verrons deux exemples concrets relatifs à ce blog. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique 727" (du 30 novembre au 6 décembre 2020).

Un nouvel article a été publié pour API Platform 3. Vous pouvez le trouver ici.

Prérequis

Je présumerai que vous avez des connaissances basiques de Symfony et que vous savez ce qu'est une API REST.

Configuration

  • PHP 8.3
  • Symfony 6.4.5
  • API Platform 2.6.6

Introduction

Je travaille / joue avec API Platform depuis quelques semaines. J'ai naturellement décidé de l'utiliser sur ce blog. Voyons comment ça c'est passé ainsi que les choses que j'ai apprises et découvertes.

But

Le but est de convertir (ou pas), deux point d'accès que j'ai sur ce blog en utilisant API Platform.

Documentation d'un point d'accès legacy

Jetons un coup d'œil à l'un des points d'accès que j'ai sur ce blog. C'est un simple contrôleur avec une action unique retournant une JsonResponse grâce au raccourci $this->json(). Elle retourne un tableau associatif avec deux clés, le nombre d'articles de blog et le nombre de 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());
    }

Voici la sortie (vous pouvez aussi vérifier la sortie ici /stats) :

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

Devrions nous convertir ce point d'accès vers API Platform ? On peut imaginer que nous pourrions faire un DTO avec deux propriétés et y accéder de part un identifiant arbitraire. L'URL pourrait ressembler à /api/stats/1acfcce8-10de-47a1-8985-d9bff214085d. Est-ce que ça vaut le coup ? En fait, non, ici nous n'avons pas besoin des fonctionnalités d'API Platform comme la pagination, les filtres, etc. Donc laissons-le tel quel. APIP peut aussi être utile sans cette situation. Nous allons l'utiliser pour documenter ce point d'accès afin qu'il apparaisse dans l'interface OpenApi.

Extension de la documentation OpenApi

Pour ce faire, nous avons besoin de décorer le service api_platform.openapi.factory. comme ci-joint (services.yaml) :


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

Voilà le décorateur :

<?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')
        ));
    }
}

Maintenant, le point d'accès est disponible dans l'interface OpenApi :

L'interface OpenApi avec le point d'accès des statistiques documenté

Nous pouvons l'essayer :

Sortie du point d'accès des statistiques dans l'interface OpenApi

Maintenant nous allons voir un cas ou nous utilisons les fonctionnalités d'APIP.

Récupération d'une liste d'articles

Sur mon portfolio professionnel. J'ai besoin de récupérer la liste du résumé des articles. Comme ce blog possède déjà une page avec la liste des articles, tout ce que j'ai à faire est de gérer un format JSON pour l'action de la liste. Voici ce que j'avais fait à la base :

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

J'avais une route list_json qui était responsable du format JSON. J'avais un template spécifique ou je faisais une boucle sur les articles pour générer le document JSON ; c'était assez moche 🤮 ! Ça marchait bien puisque Twig gère les traductions et je pouvais aussi utiliser les extensions spécifiques dont j'avais besoin. Maintenant voyons voir comment faire ça de la bonne manière avec APIP :

Tout d'abord, j'ai activé APIP sur l'entité Article grâce à l'annotation @ApiResource :

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
{

Comme j'ai des traitements spécifiques à faire pour générer la sortie (en fait comme ce qui était fait dans le template Twig), j'ai créé un DTO qui est utilisé par un convertisseur de données (DataTransformer), le voici :

<?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];
    }
}
Cliquez ici pour voir le DTO ArticleExcerpt.
<?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;
}

Ne faites pas attention au code spécifique à ce blog. La chose importante ici est la fonction transform() ; elle prend en argument une entité Article et la convertit en un DTO ArticleExerpt. Et voilà, maintenant nous pouvons essayer avec l'interface OpenApi, CURL Postman ou Postwoman.

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

Nous récupérons la sortie suivante (avec un seul article et j'ai supprimé la section hydra:search) :

{
  "@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"
  }
}

Maintenant sur mon projet portfolio, je dois interroger ce point d'accès avec les bons paramètres. Nous pouvons utiliser le client HTTP Symfony :

$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'] ?? [];

Tout d'abord nous construisons l'URL du point d'accès avec les paramètres adéquats. Nous récupérons les articles actifs (active=1) ayant le type article de blog (type=blog_post) et nous les ordonnons par ID descendant (order[id]=desc) afin d'avoir les plus récents en premier.

Gestion de la langue

Mais il y a un petit problème, comment pouvons-nous accéder à la version française des articles ? Avant, c'était géré par Symfony puisque la route contenait déjà la langue dans son chemin /{_locale}/blog/rss.json, mais les points accès standards APIP n'incluent pas la langue. Voyons voir comment faire ça proprement, nous allons passer un nouvel entête Accept-Language :

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

Cet entête Accept-Language peut contenir fr_FR ou en_EN, l'appel CURL devient :

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"

Mais ça ne fonctionne pas car cet entête n'est pas géré automatiquement par Symfony. En fait, il peut gérer cette information ; en effet, j'utilise déjà cette fonctionnalité dans l'action prenant en charge la racine du site (/) afin de rediriger l'utilisateur vers la langue la plus adaptée :


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

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

La fonction request::getPreferredLanguage() retourne quelle langue convient le mieux au navigateur client à partir d'une liste de langues disponibles. Donc mon cas, ça retourne "fr" si c'est la langue préférée du navigateur, ça retourne "en" dans tous les autres cas.

Pour l'appel APIP, nous allons aussi utiliser cette fonction très pratique getPreferredLanguage(). Nous devons créer une classe abonnée qui écoute l'événement KernelEvents::REQUEST :

<?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]],
        ];
    }
}
Cliquez ici pour voir l'écouteur de langues 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]],
        ];
    }
}

Dans cet écouteur, nous vérifions si la requête possède un entête Accept-Language. Si c'est le cas, nous récupérons la langue préférée comme précédemment. Puis nous fixons la langue ; la variable $language sera toujours définie puisque s'il n'y a pas de correspondance alors la langue par défaut (la première langue disponible) sera retournée. Cette fonction gère la validité de cette valeur, si celle-ci est incorrecte, elle sera ignorée. Elle gère aussi la localisation comme "fr_CA" qui correspondra à la langue "fr". Maintenant ça fonctionne, le contenu est bien traduit selon la langue passée dans l'entête Accept-Language. Nous pouvons tester avec les commandes suivantes :

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

Nous avons vu deux cas concrets où APIP est utile. Bien sûr, ce sont deux exemples simples, mais cela vous montre le potentiel d'APIP ; il est évolutif, flexible et peut être adapté à vos besoins quelle que soit leur complexité. J'espère que ça vous donnera envie de l'essayer si vous ne le connaissez pas déjà. Il y a de bonnes chances pour que vous ne soyez pas déçus ! 😉

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Lire la doc  Quoi de neuf dans API Platform 2.6 ?

PS : Nous mettons actuellement à jour l'application démo API Platform pour inclure plus d'exemples du "monde réel". Ceux-ci sont plus concrets que ce que la documentation peut vous proposer et sont faciles à copier-coller. À partir de là, ils sont prêts à être adaptés à vos projets. Ils respectent bien sûr les bonnes pratiques API Platform.

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : Bruce 👍

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil