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

Publié le 19/11/2022 • Mis à jour le 08/12/2022

Dans cet article, nous réécrivons un précédent article qui utilisait API Platform 2.6 avec la nouvelle version majeure d'API Platform 3. 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

Prérequis

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

Configuration

  • PHP 8.1
  • Symfony 5.4.15
  • API Platform 3.0.x (ou 2.7.x)

Introduction

Dans un précédent article, nous avons vu comment documenter et créer un nouveau point d'accès avec API Platform 2.6. Ça marchait très bien, mais nous avons dû recourir à quelques astuces, comme étendre la documentation swagger. Voyons voir comment ça peut être fait désormais avec la nouvelle version majeure 3 d'API Platform.

But

Cet article sera donc presque identique au précédent, mais grâce à API Platform 3.

Remplacement 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 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());
    }

Voici la sortie :

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

Dans le précédent article, nous avons juste documenté ce point d'accès. Maintenant, voyons comment le migrer avec API Platform 3. La première étape est de créer une ressource. Comment ce point d'accès fournit des statistiques, nous pouvons la nommer StatResource. La voici :

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

Quelques explications, cette classe est un simple POPO qui expose deux propriétés, $posts et $snippets. Celles-ci correspondent à la sortie du point d'accès legacy.
On ajoute l'attribut ApiResource avec une opération GET. Le paramètre uriTemplate correspond au chemin de la route de l'ancien contrôleur. Puis, nous associons un fournisseur à cette opération. Nous avons moins de magie avec API Platform 3. Dans ce cas, on sait directement quel service fournit les données pour cette ressource. Jetons-y un coup d'œil.

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

Ce service implémente l'interface générique ProviderInterface, qui expose une unique fonction provide(). Comme c'est une opération GET sur la ressource StatResource, le but du fournisseur est de retourner un objet StatResource, c'est tout. On récupère les statistiques du dépôt Doctrine ArticleRepository. Puis on associe les valeurs retournées aux deux propriétés de l'objet, et c'est bon. Ouvrons l'interface swagger :

L'interface OpenApi avec le pont d'accès 'stats'

Pas besoin de modifier la documentation cette fois. Tout est déjà OK, on a le point d'accès, et il retourne les statistiques au format JSON-LD. C'était différent avec le point d'accès manuel, où seules les clés posts et snippets étaient retournées (JSON simple). Aussi, veuillez remarquer que ce point d'accès est désormais préfixé avec /api puisqu'il hérite du préfixe global des ressources API Platform. Bien plus propre, n'est-ce pas ? 😊

Maintenant, voyons comment transformer un convertisseur de données 2.6 (DataTransformer) avec API Platform 3.

Récupération du résumé d'une liste d'articles

Sur mon portfolio professionnel. J'ai besoin de récupérer la liste du résumé des articles.

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

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

Cette ressource est prise en charge par Doctrine, j'ai ajouté plusieurs filtres. À présent, créons une nouvelle ressource ArticleExcerpt qui va gérer le nouveau point d'accès. La voici :

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

Quelques explications : on modifie le paramètre shortName pour que ce point d'accès apparaisse avec ceux de l'entité Article. Le paramètre uriTemplate définit le chemin du nouveau point d'accès. Finalement, on utilise une propriété additionnelle entity, celle-ci sera utilisée pour lier cette ressource avec Article.

Comme j'ai des traitements spécifiques à faire avant de retourner les données (comme la génération des URL), j'ai créé un DTO qu'un convertisseur de données va traiter. Ce service est très semblable au convertisseur de données que j'avais fait avec API Platform 2.6 à part qu'il n'implémente pas l'interface 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];
    }
}

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.

Avec API Platform 3, on a désormais des fournisseurs de données pour récupérer les ressources. Il y en a des génériques pour Doctrine : CollectionProvider et ItemProvider. Ici, nous avons besoin d'un fournisseur personnalisé, mais comme nous récupérons des articles de la base de données, nous pouvons nous appuyer sur le service CollectionProvider. Ce nouveau fournisseur est simple ; il récupère des articles comme le ferait le générique, puis il transforme la collection d'Article en un tableau de ressources ArticleExcerpt.

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

Voilà ; nous pouvons à présent essayer avec l'interface OpenApi, CURL ou 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"

On obtient la sortie suivante (avec un article, et j'ai supprimé la section hydra:search)


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

Maintenant, on doit interroger le nouveau point d'accès avec les paramètres adéquats dans mon projet portfolio. On peut utiliser le client HTTP Symfony.

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

Tout d'abord, on construit l'URL du point d'accès avec les bons paramètres. On récupère les articles actifs (active=1), de type "blog" (type=blog_post), et on les trie par ID descendant (order[id]=desc) pour avoir les plus récents en premier.

Gestion de la langue

Cette section est identique au précédant article. Vous pouvez le trouver ici.

Découvrez API Platform 3

Vous voulez en savoir plus sur API Platform 3 ? Vous pouvez lires ces slides de Kevin :

Vous pouvez aussi accéder à toutes les vidéos des prises de paroles effectuées lors de la API Platform con 2022.

Merci à Antoine Bluchet (Soyuka) pour m'avoir aidé à optimiser cet article. Une PR devrait arriver pour pouvoir utiliser une propriété additionnelle pour associer une ressource à une autre comme nous venons de le faire.

Conclusion

On utilise désormais API Platform 3 ; comme vous pouvez le voir, on a moins de magie et nous n'avons pas eu à modifier la doc swagger cette fois. Dans les entités, nous voyons les fournisseurs correspondant à chaque opération. Ces fournisseurs sont simples à créer puisqu'une seule fonction provide() doit être implémentée. 😉

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  Plus sur Tweeter

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


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