Retour aux articles

L'agence

WanadevStudio

Créer facilement une API REST avec Symfony & API Platform

Au menu aujourd'hui, je vous ai concocté un petit article gourmand croquant, sur un framework devenu très populaire au sein de la communauté Symfony dans la mise en place d'API, j’ai nommé le fameux API Platform.

API Platform, kézako ?

Pensé initialement par Les Tilleuls, et plus précisément par Kévin Dunglas, API Platform est un framework dont l’intérêt principal réside dans sa capacité à mettre en place une API de la manière la plus simple, rapide et efficace qu’il soit.

Fini le temps où vous passiez une éternité à coder from scratch votre API, dorénavant avec API Platform vous pourrez concevoir une API REST en quelques minutes tout en respectant les standards actuels (JSON-LD avec Hydra, OpenAPI…) et en gardant une grande flexibilité (ressources au format JSON,XML ou CSV….) !

En plus de créer rapidement une API REST, API Platform génère automatiquement une interface utilisateur (Swagger) qui permet à quiconque - que ce soit votre équipe de développement ou vos partenaires techniques - de visualiser et d'interagir avec les ressources de l'API facilitant la mise en œuvre back-end et la consommation côté client.

Mais oui c’est super ! Alors plongeons-nous sans plus attendre sur le sujet. Vamos !

API Platform, c’est trop swag !

Afin de découvrir la création d'une api avec API Platform je vous propose d’installer et configurer une nouvelle application Symfony Flex.

Installez le symfony/skeleton qui sera parfaitement adapté à ce tutoriel. Suivez le guide d’installation Symfony.

Ok vous êtes tout bon ? Vous avez créé votre projet , installé toutes les dépendances ? Super alors vous devriez avoir ceci sur la page d’accueil de votre application

Si c’est le cas bravo, on va pouvoir installer API Platform dans notre tout nouveau projet :

Pour les besoins de cet article nous allons rester centrés sur le composant API, mais sachez que la distribution officielle d'API Platform propose un framework bien plus complet. En plus du composant API, s'ajoute une interface Admin (React-Admin) et une interface client (Next.js,Vue.js, React...). Le framework dispose également des outils de développement et de déploiement les plus performants (Docker & Kubernetes), d'un système de cache (Varnish) et de push data updates (Mercure). Pour en savoir plus, visitez la documentation officielle d'API Platform.

$ composer require api

Au moment où j’écris cet article la version 2.6.2 de la librairie api-platform/core contient un bug (ksort() expects parameter 1 to be array, object given ). Pour le résoudre c’est assez simple downgradez la librairie à 2.5.*.

Installation terminée ? Oui ? Alors rendez-vous à l’adresse suivante : url_de_votre_site/api.

Pas d’inquiétude j’ai eu exactement la même réaction que vous lorsque j’ai vu cette interface la première fois.

Alors à quoi correspond-elle ? Souvenez-vous je vous en avais parlé un peu plus tôt. Il s’agit de la fameuse interface Swagger UI.

Noooooooooooooon ! Pas Swagg Man ! Swagger UI !

Swagger UI c'est l'interface que vous avez sous les yeux. Elle est générée sur la base de vos spécifications OpenAPI. Alors pour rappel OpenAPI Specification (OAS), anciennement connue sous le nom de spécification Swagger, est la norme internationale pour la définition des interfaces RESTful. Il s’agit d’un fichier JSON ou YAML qui contient toutes vos routes d’API, une description de ce que chacune fait, les paramètres à renseigner, les réponses attendues. En résumé OAS essaie essentiellement de décrire votre API.

Vous pouvez d’ailleurs jeter un œil sur le contenu de votre OAS à l’adresse suivante : url_de_votreapp/api/docs.json

Ainsi à partir des spécifications OpenAPI, Swagger UI génère une interface qui permettra à vous, développeur, mais aussi aux consommateurs de votre API, de visualiser et interagir avec votre API.

Certes pour le moment, cette vue est totalement vide car nous n’avons aucune ressource, donc aucune opération de définie. Et d'ailleurs nous n’avons aucune information concernant l’API (tire, description, version).

Pour renseigner au mieux les futurs utilisateurs de votre API nous allons la documenter un peu. Ouvrez le fichier api_platform.yaml qui se trouve dans config/packages et insérez le contenu suivant :

api_platform:
   title: 'Symfony API Platform REST API'
   description: 'A Symfony API to manage a library'
   version: '1.0.0'
   mapping:
       paths: ['%kernel.project_dir%/src/Entity']
   patch_formats:
       json: ['application/merge-patch+json']
   swagger:
       versions: [3]

Rechargez la page et vous devriez obtenir le résultat suivant :

Maintenant on va exposer notre première ressource, pour cela on va travailler sur les entités doctrine. Et vous allez vite constater que c’est assez simple de créer votre API.

S’entêter sur les entités !

Vous l’aurez donc deviné, compte tenu du titre de cette section, nous allons dorénavant travailler sur les entités car c’est là une des forces d’API Platform.

Partons par exemple sur la création d’une entité Book avec Symfony MakerBundle

$ php bin/console make:entity
$  Class name of the entity to create or update (e.g. OrangeGnome):
 > Book

Dorénavant le terminal nous demande si nous voulons faire de cette classe une ressource. Ici c’est le bundle API Platform qui nous le demande. A cette question on répond bien entendu que oui :

$   Mark this class as an API Platform resource (expose a CRUD API for it) (yes/no) [no]:
 > yes

L'entité Book sera donc exposée comme ressource de notre API. Vous pouvez ensuite ajouter le reste des attributs, comme ci-dessous, pour compléter votre entité Book.

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $title;

    /**
     * @ORM\Column(type="text")
     */
    private $summary;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $author;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $isbn;

    /**
     * @ORM\Column(type="datetime")
     */
    private $publicationDate;

    /**
     * @ORM\Column(type="datetime")
     */
    private $createdAt;

    /**
     * @ORM\Column(type="datetime")
     */
    private $updatedAt;

// ...

Une fois terminé, ouvrez le fichier src/Entity/Book.php, vous verrez avant la définition de la classe l'annotation @ApiResource(). Cela signifie tout simplement que votre entité est une ressource de votre API. Conséquence, cette dernière est ajoutée automatiquement à votre documentation API.

API Platform nous génère toutes les opérations pour l’entité Book. On peut dorénavant créer un livre, le modifier, le supprimer et lire toute la liste des livres présents en base.

Pour info lorsque nous avons installé le bundle API Platform, sa recette a ajouté un fichier config/routes/api_platform.yaml, dans lequel vous allez retrouver le point d’entrée de votre api (par défaut /api). A partir de là toutes les classes marquées de @ApiResource créeront 5 nouvelles routes pour les 5 opérations et en préfixant toutes les URL avec /api. Si vous souhaitez modifier les URL d'API, remplacez simplement le préfixe dans la configuration.

Donc en résumé, sans même écrire une seule ligne de code, vous avez créé une API documentée et opérationnelle . Vous êtes trop fort. Bravo !

Comprendre les opérations d'Api Platform

Api Platform repose sur le concept des opérations, une opération c'est un lien entre une ressource, une route et son contrôleur associé. On distingue deux types d'opérations :

  • CollectionOperations : Opérations sur les collections agissant sur un ensemble de ressources.
  • ItemOperations : Opérations d'éléments agissant sur une ressource individuelle.

Voici en détail les opérations :

Opérations sur les collections
Méthode HTTP Description
GET Récupérer la liste (paginée) des éléments
POST Créer un nouvel élément
Opérations sur les éléments
Méthode HTTP Description
GET Récupérer un élément
PUT Remplace un élément
PATCH Applique une modification partielle sur un élément
DELETE Supprime un élément

Toutes ces opérations sont activées par défaut, mais il est possible en fonction de vos besoins de désactiver certaines d’entre elles. Les opérations peuvent être configurées à l'aide d'annotations, XML ou YAML. En suivant l'exemple suivant vous pouvez faire le test.

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\BookRepository;
use Doctrine\ORM\Mapping as ORM;

/**
/**
* @ApiResource(
*     collectionOperations={"get"},
*     itemOperations={"get", "put", "delete"}
* )
 * @ORM\Entity(repositoryClass=BookRepository::class)
 */
class Book

Ici nous ne mentionnons que les opérations dont nous avons besoin. Celles qui ne sont pas renseignées, en l'occurrence la méthode Patch des itemOperations et Post des collectionOperations ne seront plus accessibles.

Pensez à bien revenir en arrière avant de passer au point suivant. Nous allons conserver toutes les opérations.

Serializer & Deserializer

Tout le processus de transformation de notre objet Book en JSON ... et JSON de nouveau en objet Book, est effectué par le Serializer de Symfony !

Passer d'un objet à JSON est appelé sérialisation, et de JSON à un objet appelé désérialisation. Pour ce faire, en interne, il passe par un processus appelé normalisation : il prend d'abord votre objet et le transforme en tableau. Et puis il l’encode au format JSON (ou XML par exemple). Et la dénormalisation c’est tout simplement l’inverse.

API Platform utilise les attributs normalizationContext et denormalizationContext, dans l' annotation @ApiResource, pour définir ce que l'on appelle des groupes. Les propriétés de l'entité sont ensuite ajoutées ou non à tel ou tel groupe grâce à l'annotation @Groups :

Ainsi :

  • Les propriétés qui se trouveront dans le groupe du normalizationContext seront accessibles en mode lecture (GET).
  • Les propriétés qui se trouveront dans le groupe du denormalizationContext seront accessibles en mode écriture (POST, PUT, PATCH).
  • Les propriétés qui se trouveront dans les 2 groupes seront accessibles en mode lecture et écriture.
  • Les propriétés qui n'ont aucun groupe ne seront pas exposées.

Essayez donc ceci sur notre entité Bok :

/**
* @ApiResource(
*     normalizationContext={"groups"={"book:read"}},
*     denormalizationContext={"groups"={"book:write"}}
* )
* @ORM\Entity(repositoryClass=BookRepository::class)
*/
class Book
{
   /**
    * @ORM\Id
    * @ORM\GeneratedValue
    * @ORM\Column(type="integer")
    */
   private $id;

   /**
    * @Groups({"book:read", "book:write"})
    * @ORM\Column(type="string", length=255)
    */
   private $title;

   /**
    * @Groups({"book:read", "book:write"})
    * @ORM\Column(type="text")
    */
   private $summary;

   /**
    * @Groups({"book:read", "book:write"})
    * @ORM\Column(type="string", length=255)
    */
   private $author;

   /**
    * @Groups({"book:read", "book:write"})
    * @ORM\Column(type="string", length=255)
    */
   private $isbn;

   /**
    * @Groups({"book:read", "book:write"})
    * @ORM\Column(type="datetime")
    */
   private $publicationDate;

   /**
    * @Groups({"book:read"})
    * @ORM\Column(type="datetime")
    */
   private $createdAt;

   /**
    * @Groups({"book:read"})
    * @ORM\Column(type="datetime")
    */
   private $updatedAt;

// ...

Rechargez la page et jetez un œil à la section Schémas. Vous voyez que ces derniers ont été remis à jour avec les nouveaux groupes. Par ailleurs si vous vous rendez sur le endpoint POST api/books vous allez vite vous apercevoir que vous ne nécessitez que de 5 paramètres pour enregistrer un nouvel article alors que par le passé vous deviez renseigner tous les attributs (7) de l’entité. Ceci a d'ailleurs son importance et vous allez vite saisir pourquoi.

DataPersister

Nous sommes quasiment prêts à tester notre API. Mais au préalable il faut commencer par créer notre base de données.

N’oubliez pas de renseigner l’url de votre base de données dans le fichier .env ou .env.local de votre application.

$   php bin/console doctrine:database:create

Créez et faites les migrations :

$   php bin/console make:migration
$   php bin/console doctrine:migrations:migrate

La base de données est prête mais nous n’avons aucune donnée pour le moment.

Pas de souci quelques fixtures et le tour est joué :

$   composer require --dev orm-fixtures
  • Ensuite on crée notre fixture dans un fichier nommé BookFixtures et que l'on place dans le dossier suivant src/DataFixtures
<?php

namespace App\DataFixtures;

use App\Entity\Book;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class BookFixtures extends Fixture
{
    public function load(ObjectManager $manager)
    {
        for ($i = 0; $i < 100; $i++) {
            $book = new Book();
            $book->setTitle('Book '.$i);
            $book->setAuthor('Author '.$i);
            $book->setIsbn('928-92-95055-02-'.$i);
            $book->setSummary('Summary '.$i);
            $book->setPublicationDate(new \DateTime('2017-08-31 00:00:00'));
            $book->setCreatedAt(new \DateTime('now'));
            $book->setUpdatedAt(new \DateTime('now'));
            $manager->persist($book);
        }

        $manager->flush();
    }
}
  • Enfin lançons la commande suivante :
$   php bin/console doctrine:fixtures:load

Votre table book comporte dorénavant 100 livres. Super !

Rendez-vous donc sur la documentation API et cliquez sur le premier endpoint (GET) > Try it out > Execute. Dans un premier temps on constate la présence d’une section Parameters, dans laquelle est affichée une clé page de type integer. C’est tout simplement le numéro de la page de votre collection. En effet par défaut API Platform pagine vos résultats et renvoie 30 éléments par page et heureusement d’ailleurs sinon toutes vos données serait entièrement renvoyées et dans le cadre d'une table avec plusieurs milliers d'entrées cela poserait de gros problèmes de performance.

Vous pouvez modifier le nombre d'éléments par page directement dans la configuration du package API Platform config/packages/api_platform.yaml

On continue avec la seconde section qui représente tout simplement les résultats de la requête sous le format JSON-LD. C’est toujours du JSON mais avec des métadonnées supplémentaires qui permettent d’expliciter le contexte.

Si l’on teste le second endpoint (POST) afin de sauvegarder un nouveau livre on se confronte rapidement à une erreur 500 qui nous empêche de l'enregistrer

"An exception occurred while executing 'INSERT INTO book (id, title, summary, author, isbn, publication_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' with params [101, \"string\", \"string\", \"string\", \"string\", \"2021-02-26 15:58:14\", null, null]:\n\nSQLSTATE[23502]: Not null violation: 7 ERROR:  null value in column \"created_at\" violates not-null constraint\nDETAIL:  Failing row contains (101, string, string, string, string, 2021-02-26 15:58:14, null, null)."

Pour le moment, quand nous faisons une requête POST, API Platform gère automatiquement l'enregistrement de notre entité en bdd. Il prend le JSON que nous lui envoyons, instancie un objet et le persiste. Le souci ici c’est que nous avons défini des attributs en lecture seule comme le createdAt et le updatedAt, Dès lors que nous enregistrons un nouveau livre, nous n'envoyons pas ces attributs dans le JSON alors que dans notre table book en bdd, nous avons défini le createdAt et updatedAt à nullable = false., ce qui signifie que ces dernier doivent obligatoirement être renseignés. Conséquence, on arrive obligatoirement à une erreur 500.

Alors vous me direz , pourquoi ne pas renseigner tout simplement ces attributs ? Et bien moi je vous réponds que non car les dates de création et de mise à jour doivent être générées automatiquement.

Dès lors qu’est ce qu’on fait ? Et bien on va tout simplement implémenter ce que l’on appelle le DataPersister.

Pour faire muter les états de l'application lors des opérations POST, PUT, PATCH ou DELETE, API Platform utilise des classes appelées DataPersister. Ces classes reçoivent une instance de la classe déclarée comme une ressource API et contient des données soumises par le client pendant le processus de désérialisation.

Ainsi en ce qui nous concerne nous allons ajouter dynamiquement les champs manquants dans ce processus et valider ainsi notre ajout de livre.

Pour ce faire nous allons créer une nouvelle classe BookDataPersister, qui implémente l’interface DataPersister d’API Platform, dans le dossier src/DataPersister :

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\DataPersisterInterface;
use App\Entity\Book;
use Doctrine\ORM\EntityManagerInterface;

class BookDataPersister implements DataPersisterInterface
{

   private $entityManager;

   public function __construct(EntityManagerInterface $entityManager)
   {
       $this->entityManager = $entityManager;
   }

   public function supports($data): bool
   {
       return $data instanceof Book;
   }

   /**
    * @param Book $data
    *
    * @return void
    */
   public function persist($data)
   {
       if (!$data->getId()) {
           $data->setCreatedAt(new \DateTime('now'));
       }
       $data->setUpdatedAt(new \DateTime('now'));

       $this->entityManager->persist($data);
       $this->entityManager->flush();
   }

   public function remove($data)
   {
       $this->entityManager->remove($data);
       $this->entityManager->flush();
   }
}
}

J'ai choisi de vous montrer les DataPersisters à travers cet exemple simple mais je vous conseille vivement d'utiliser les Events Doctrine pour des attributs comme createdAt et updatedAt.

En résumé on va dire à API Platform qu’à partir du moment où il crée un nouveau livre il doit renseigner une nouvelle date de création. En parallèle, peu importe s’il s’agit d’une création ou édition de livre, API Platform va devoir enregistrer une date de mise à jour.

Je vous invite ainsi à tester une nouvelle fois le endpoint Post api/posts et vous devriez obtenir une réponse 201 qui indique que la requête a réussi et qu’une nouvelle ressource a été créée.

Validation

Dernière chose que je souhaitais voir avec vous, et promis ensuite je vous laisse tranquille, il s'agit de la validation des données. En effet un client API peut envoyer des données incorrectes : il peut envoyer du JSON malformé ou bien encore envoyer un champ de titre vide tout simplement parce qu'un utilisateur a oublié de remplir un champ sur le frontend .... Le travail de notre API est de répondre à toutes ces situations de manière informative et cohérente afin que les erreurs puissent être facilement comprises, analysées et communiquées. C'est l'un des domaines dans lequel API Platform excelle vraiment en s'appuyant sur le puissant composant Symfony Validator. Grâce à ce composant l'ajout des règles de validation devient très simple, il ne vous suffit qu'à rajouter quelques annotations (@Assert) sur vos attributs et le tour est joué :

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\BookRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ApiResource(
 *     normalizationContext={"groups"={"book:read"}},
 *     denormalizationContext={"groups"={"book:write"}}
 * )
 * @ORM\Entity(repositoryClass=BookRepository::class)
 */
class Book
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @Groups({"book:read", "book:write"})
     * @Assert\NotBlank()
     * @ORM\Column(type="string", length=255)
     */
    private $title;

    /**
     * @Groups({"book:read", "book:write"})
     * @Assert\NotBlank()
     * @ORM\Column(type="text")
     */
    private $summary;

    /**
     * @Groups({"book:read", "book:write"})
     * @Assert\NotBlank()
     * @ORM\Column(type="string", length=255)
     */
    private $author;

    /**
     * @Groups({"book:read", "book:write"})
     * @Assert\Isbn()
     * @ORM\Column(type="string", length=255)
     */
    private $isbn;

    /**
     * @Groups({"book:read", "book:write"})
     * @Assert\Type("\DateTimeInterface")
     * @ORM\Column(type="datetime")
     */
    private $publicationDate;
//..

A présent tester le endpoint POST api/books sans rien changer à votre body et vous devriez logiquement obtenir une erreur 400, indiquant que votre isbn n'est pas au bon format. Si c'est le cas veuillez renseigner un isbn au bon format comme dans l'exemple suivant et vous devriez avoir un retour 201 :

{
  "title": "string",
  "summary": "string",
  "author": "string",
  "isbn": "978-92-95055-02-5",
  "publicationDate": "2021-03-01T11:37:09.716Z"
}

Conclusion

Au cours de cet article nous avons couvert les concepts de base de la création d'une API REST avec Symfony & API Platform, mais ayez bien à l'esprit qu'il en existe beaucoup d'autres tout aussi importants et qui nécessitent que vous y consacriez un peu de temps. Je vous invite à ce propos à lire la documentation officielle qui est très complète.

Quand à moi je vous retrouve très vite pour aborder d'autres sujets concernant API Platform, comme la relation entre les entités, les autorisations & l'authentification.

Merci pour votre attention & à très vite.

Commentaires

Un article qui file tout droit dans le wiki :)