Introduction à l’API REST avec Symfony 6 & DB sous docker
À la fin de cette introduction, vous aurez une maquette très basique d’interface de programmation d’application (API) REST (Representational State Transfer) Symfony se connectant sur une base de données et fonctionnant avec docker. L’objectif est de comprendre comment mettre en place la solution et non de fournir un exemple finalisé.
Pour notre prototype, nous prenons le cas d’une bibliothèque référençant des livres et des auteurs.
L’API nous permettra d’ajouter, de supprimer, de lister ou d’obtenir le détail d’un livre. Elle nous permettra aussi d’ajouter, lister et supprimer des auteurs.
Prérequis
Pour avoir un projet “production ready” et éviter le “ça ne fonctionne pas chez moi”, nous partons sur un environnement docker et docker-compose.
Il sera donc nécessaire de les installer sur votre environnement.
Suivez les liens pour installer docker et docker-compose sur votre Windows, Linux ou Mac
L’infrastructure
La structure du projet
La première étape consiste à créer l’arborescence du projet.
Dans un dossier racine, nous mettons d’une part ce qui concerne l’infrastructure de docker et d’autre part la partie applicative.
mkdir Library && cd Library
mkdir docker && mkdir docker/php && mkdir docker/nginx && mkdir docker/logs && mkdir project
Maintenant que nos dossiers sont créés, nous passons à la configuration des containers docker.
Configuration de PHP
Pour configurer PHP, nous parcourons le sous-dossier “docker” puis “php” et nous créons le fichier “Dockerfile”.
cd docker/php
touch Dockerfile
Le “Dockerfile” permet de faire fonctionner le container utilisant PHP. Nous recommandons la dernière version de PHP pour l’image du container.
Dans votre éditeur préféré, vous pouvez éditer le fichier “Dockerfile” comme il suit :
# Specify the parent image from which we are building
FROM php:8.1-fpm
# Update and install lib & php extension
RUN apt-get update \
&& apt-get install -y zlib1g-dev g++ git libicu-dev zip libzip-dev zip \
&& docker-php-ext-install intl \
&& docker-php-ext-configure zip \
&& docker-php-ext-install zip
# Copy the Composer PHAR from the Composer image into the PHP image
COPY --from=composer /usr/bin/composer /usr/bin/composer
# Sets the working directory for the instruction that follow it
WORKDIR /var/www
# Install composer
CMD composer install ; php-fpm
# Install symfony
RUN curl -sS https://get.symfony.com/cli/installer | bash
RUN mv /root/.symfony5/bin/symfony /usr/local/bin/symfony
# Inform docker that the container listens on the specified network ports at runtime
EXPOSE 9000
S’agissant des bibliothèques et extensions, vous devrez en ajouter un certain nombre pour que cela corresponde à vos besoins. Lors de cette introduction, nous en ajouterons davantage notamment pour la partie base de données.
Configuration du serveur
Dans cette introduction, nous configurons un serveur Nginx, cependant, il reste possible d’utiliser un autre serveur tel qu’Apache.
Comme pour le container PHP, nous allons créer un fichier “Dockerfile” dans le sous-dossier Nginx, puis nous ajoutons un fichier “nginx.conf”.
cd docker/nginx
touch Dockerfile && touch nginx.conf
Dans votre éditeur préféré, vous pouvez éditer le fichier “Dockerfile” comme il suit :
# Specify the parent image from which we are building
FROM nginx:alpine
# Sets the working directory for the instruction that follow it
WORKDIR /var/www
# Run nginx
CMD ["nginx"]
# Inform docker that the container listens on the specified network ports at runtime
EXPOSE 80
Pour que Nginx fonctionne correctement, nous éditons le fichier de configuration “nginx.conf”
user nginx;
worker_processes 5;
daemon off;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
server {
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
server_name localhost;
root /var/www/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
try_files $uri /index.php =404;
fastcgi_pass php-fpm:9000;
fastcgi_index index.php;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 600;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
}
}
Pour plus d’information sur Nginx : https://www.nginx.com/resources/wiki/start/topics/examples/full/
https://www.nginx.com/resources/wiki/start/topics/recipes/symfony/
https://symfony.com/doc/current/setup/web_server_configuration.html
Si vous réutilisez cet exemple, vous devrez ajouter un sous-dossier “nginx” dans le dossier “logs”, puis deux fichiers “access.log” et “error.log”
cd ../logs && mkdir nginx && touch nginx/access.log && touch nginx/error.log
Configuration du docker-compose
Pour la création du docker-compose, nous retournons à la racine du projet et nous créons le fichier “docker-composer.yml”
Dans celui-ci, nous ajoutons la description de notre container php et serveur :
version: '3.5'
services:
php:
container_name: "php-fpm"
build:
context: ./docker/php
volumes:
- ./project/:/var/www
nginx:
container_name: "nginx"
build:
context: ./nginx
volumes:
- ./project/:/var/www
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./docker/logs:/var/log
depends_on:
- php
ports:
- "80:80"
Version commentée :
# Schema version
version: '3.5'
# Start listing of service (containers)
services:
# Service name that will become network alias
php:
# Container name rather than a generated default name
container_name: "php-fpm"
# Build context that can be string or object as following
build:
# Path to the build context
context: ./docker/php
# Mechanism for persisting data generated by and used by Docker containers
volumes:
# Left is local directory and right is destination in docker container
- ./project/:/var/www
nginx:
container_name: "nginx"
build:
context: ./nginx
volumes:
- ./project/:/var/www
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./docker/logs:/var/log
# Express dependency between services. ! depends_on doesn’t wait other service to be ready !
depends_on:
# Need php service to work.
- php
# Expose ports
ports:
- "80:80"
Configuration de la base de données
Nous avons généralement besoin d’une base de données dans les projets. Dans le code fourni, nous trouvons PostgreSQL de configuré, cependant ci-dessous, vous trouverez aussi un exemple avec MySQL.
Ajouter une base de données PostgreSQL
Dans le fichier “docker-compose.yml”, directement après “services”, ajoutez ces lignes (Vérifiez l’indentation lorsque vous ajoutez ce bloc):
database:
container_name: db
image: “postgres:15.0”
restart: always
environment:
POSTGRES_USER: postgres_user
POSTGRES_PASSWORD: password
POSTGRES_DB: dbtest
ports:
- 15432:5432
Dans la description du service php, il faut ajouter :
depends_on:
- database
Ce qui donne :
version: '3'
services:
database:
container_name: database
image: postgres:13.0
environment:
POSTGRES_USER: postgres_user
POSTGRES_PASSWORD: password
POSTGRES_DB: dbtest
ports:
- 15432:5432
php:
container_name: "php-fpm"
build:
context: ./docker/php
depends_on:
- database
volumes:
- ./project/:/var/www
nginx:
container_name: "nginx"
build:
context: ./docker/nginx
volumes:
- ./project/:/var/www
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./docker/logs:/var/log
depends_on:
- php
ports:
- "80:80"
Dans le fichier “Dockerfile” présent dans le dossier “php”, vous devez ajouter les dépendances nécessaires au fonctionnement de la base de données, ce qui nous donne :
RUN apt-get update \
&& apt-get install -y zlib1g-dev g++ git libicu-dev zip libpq-dev libzip-dev zip \
&& docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql \
&& docker-php-ext-install intl opcache pdo pdo_mysql pdo_pgsql pgsql \
&& docker-php-ext-enable pdo_pgsql \
&& docker-php-ext-configure zip \
&& docker-php-ext-install zip
Ajouter une base de données MySQL
Dans le fichier “docker-compose.yml”, directement après “services”, ajoutez ces lignes (Vérifiez l’indentation lorsque vous ajoutez ce bloc):
database:
container_name: database
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: dbtest
MYSQL_USER: mysql_user
MYSQL_PASSWORD: password
ports:
- '4306:3306'
volumes:
- ./mysql:/var/lib/mysql
Dans la description du service php, il faut ajouter :
depends_on:
- database
Ce qui donne :
version: '3'
services:
database:
container_name: database
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: dbtest
MYSQL_USER: mysql_user
MYSQL_PASSWORD: password
ports:
- '4306:3306'
volumes:
- ./mysql:/var/lib/mysql
php:
container_name: "php-fpm"
build:
context: ./docker/php
depends_on:
- database
volumes:
- ./project/:/var/www
nginx:
container_name: "nginx"
build:
context: ./docker/nginx
volumes:
- ./project/:/var/www
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./docker/logs:/var/log
depends_on:
- php
ports:
- "80:80"
Le dossier “docker/mysql” va se créer automatiquement.
Dans le fichier “Dockerfile” présent dans le dossier “php”, vous devez ajouter les dépendances nécessaires au fonctionnement de la base, ce qui nous donne :
RUN apt-get update \
&& apt-get install -y zlib1g-dev g++ git libicu-dev zip libzip-dev zip \
&& docker-php-ext-install intl pdo pdo_mysql \
&& docker-php-ext-enable pdo_mysql \
&& docker-php-ext-configure zip \
&& docker-php-ext-install zip
Première exécution du projet
Nous vérifions que toutes les configurations que nous avons faites sont bonnes. Pour cela, nous allons de nouveau à la racine du projet (où se trouve le docker-compose).
Sous Windows, vous devez préalablement démarrer Docker Desktop.
Puis dans un terminal :
docker-compose up -d --build
Si tout s’est bien déroulé, vous devriez constater trois containers en cours d’exécution avec la commande :
docker-compose ps
À ce stade, nous n’avons pas encore partie applicative de configurée. Nous allons donc voir ça immédiatement.
Configuration de la partie applicative
Pour toutes la configuration de Symfony, nous allons le faire à partir du container php.
Dans votre terminal, saisissez :
docker exec -it php-fpm bash
Si vous utilisez par exemple git Bash sous Windows, vous pourriez obtenir le message suivant :
the input device is not a TTY. If you are using mintty, try prefixing the command with ‘winpty’
Dans ce cas, il vous suffit d’ajouter “winpty” devant la précédente commande :
winpty docker exec -it php-fpm bash
Dans le terminal attaché au container, nous pouvons maintenant générer le projet Symfony :
symfony new . --version="6.1.*"
Le “.” correspond au dossier courant du container soit par défaut “var/www”. Puisque nous avons mis dans notre docker compose “./project/:/var/www”, ce qui se génère dans le container “var/www” apparait automatiquement dans le dossier local “./project”.
Vous devriez obtenir le message suivant :
Author identity unknown
*** Please tell me who you are.
Run
git config --global user.email "you@example.com"
git config --global user.name "Your Name"
to set your account's default identity.
Omit --global to set the identity only in this repository.
fatal: unable to auto-detect email address (got 'root@c92b832186cf.(none)')
exit status 128
Même si nous obtenons ce message, notre projet s’est bien généré.
La commande a automatiquement généré deux fichiers docker-compose que nous pouvons supprimer.
rm docker-compose.override.yml && rm docker-compose.yml
Nous allons maintenant ajouter les dépendances nécessaires au projet.
Nous utilisons :
- “orm-pack” pour la communication entre l’application et la base de données
- “maker-bundle” pour générer les entités, contrôleurs, etc.
- “serializer-pack” pour effectuer des transformations entre JSON, array et entité
composer require symfony/orm-pack
composer require --dev symfony/maker-bundle
composer require symfony/serializer-pack
Nous ouvrons le “.env” du dossier “project” pour modifier la ligne “DATABASE_URL”.
Pour PostgreSQL :
DATABASE_URL="postgresql://postgres_user:password@database:5432/dbtest?serverVersion=15&charset=utf8"
Pour MySQL :
DATABASE_URL="mysql://mysql_user:password@database:3306/dbtest?serverVersion=8.0"
En ouvrant votre navigateur web et en saisissant http://localhost/ vous devriez voir la page de bienvenue de Symfony
Partie applicative
Pour rappel, nous prenons le cas d’une bibliothèque référençant des livres et des auteurs. Nous allons suivre les relations suivantes :
Prérequis
Toutes les commandes effectuées sur un terminal se font à partir du container php. Rappel de la commande pour attacher un terminal à votre container :
docker exec -it php-fpm bash
Préparation du modèle
symfony console make:entity
symfony console make:entity
Nous allons maintenant préciser nos entités.
Ouvrez l’entité “Author” et ajoutez juste avant “class Author” une contrainte unique sur “firstname” et “lastname” :
#[UniqueConstraint(name: 'U_firstname_lastname', columns:['firstname', 'lastname'])]
Sur l’entité “Book”, nous allons ajouter la contrainte sur “title” et “published_date” :
#[UniqueConstraint(name: 'U_title_date', columns:['title', 'published_date'])]
Vous devriez avoir quelque-chose comme ci-dessous :
...
#[ORM\Entity(repositoryClass: BookRepository::class)]
#[UniqueConstraint(name: 'U_title_date', columns:['title', 'published_date'])]
class Book
{
...
}
Préparation des contrôleurs
Dans votre terminal, saisissez :
symfony console make:controller
symfony console make:controller
Création des routes pour “Author”
Ci-dessous un diagramme pour comprendre ce qu’il se passe dans les grandes lignes lors d’une requête GET.
Pour la création des différentes routes, nous avons besoin d’importer ces éléments :
use App\Entity\Author;
use App\Repository\AuthorRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;
1- Route pour créer un auteur
POST http://localhost/author
{
"firstname":"Prénom",
"lastname":"Nom"
}
Ci-dessous le code du contrôleur :
#[Route('/author', name: 'author_create', methods:['POST'])]
public function createAuthor(Request $request, SerializerInterface $serializer, AuthorRepository $authorRepository): JsonResponse
{
$author = $serializer->deserialize($request->getContent(), Author::class, 'json');
try {
$authorRepository->save($author, true);
}
catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $th) {
return $this->json('Conflic : Traget resource already exist', 409);//https://www.rfc-editor.org/rfc/rfc9110
}
return $this->json($author, 201, array('Location' => '/author/' . $author->getId()));
}
La route “/author” reste au singulier, car elle n’a pas vocation à créer plusieurs auteurs. Les noms des différentes routes sont toujours précédés de “author_” pour que l’on puisse plus facilement les repérer dans le debug de route (nous l’aborderons plus tard). Nous devrions comprendre le rôle d’une méthode uniquement en lisant son nom, il doit donc rester intelligible.
Le “serializer” permet de directement transformer le JSON reçu en une entité. Le “authorRepository” nous permet de traiter la sauvegarde dans le repository.
Nous avons deux retours possibles, soit un 409 lorsque la ressource existe déjà, soit un 201 lorsque nous créons la ressource.
Pour plus d’information sur les code statut http : https://www.rfc-editor.org/rfc/rfc9110
2- Route pour lister tous les auteurs
GET http://localhost/authors
#[Route('/authors', name: 'authors_all', methods:['GET'])]
public function getAuthors(SerializerInterface $serializer, AuthorRepository $authorRepository): JsonResponse
{
$authors = $authorRepository->findAll();
$serializedAuthors = $serializer->serialize($authors, 'json', [
'circular_reference_handler' => function ($object) { return $object->getId(); }
]);
return JsonResponse::fromJsonString($serializedAuthors, 200);
}
Nous ajoutons ici une nouvelle notion “circular_reference_handler”. Cela permet tout simplement d’éviter une boucle infinie (l’auteur qui charge le livre qui charge l’auteur, etc.). Dans cette version très simple, nous retournons uniquement l’id de l’objet qui génère la référence circulaire.
Étant donné que nous avons sérialisé l’entité en json en amont, nous faisons directement appel à la méthode “fromJsonString” pour construire la réponse.
3- Route pour effacer un auteur
DELETE http://localhost/author/{authorId}
#[Route('/author/{authorId}', name: 'author_delete', methods:['DELETE'])]
public function deleteAuthor(AuthorRepository $authorRepository, int $authorId): JsonResponse
{
$author = $authorRepository->findOneById($authorId);
if ($author === null)
return $this->json(null, 404);
else
$authorRepository->remove($author, true);
return $this->json(null, 204);//202 if queued
}
Il s’agit d’une introduction, nous pouvons donc optimiser le code. Par exemple, plutôt que de réaliser une requête pour récupérer l’entité à supprimer, nous pourrions directement faire une requête pour supprimer l’entité et gérer les retours d’erreurs.
Dans le cas où nous aurions un système de queue, par exemple “RabbitMQ”, nous pourrions retourner un code 202 à l’utilisateur.
Création des routes pour “Book”
Pour créer les différentes routes, nous avons besoin d’importer ces éléments :
use App\Entity\Author;
use App\Entity\Book;
use App\Repository\BookRepository;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;
1- Route pour créer un livre
{
"title":"Titre du livre",
"publishedDate":"2020-08-19",
"author": {"id":1}
}
Pour le contrôleur :
#[Route('/book', name: 'book_create', methods: ['POST'])]
public function createBook(Request $request, ManagerRegistry $doctrine, SerializerInterface $serializer): JsonResponse
{
$book = $serializer->deserialize($request->getContent(), Book::class, 'json');
$em = $doctrine->getManager();
$authorReference = $em->getReference(Author::class, $book->getAuthor()->getId());
$book->setAuthor($authorReference);
try {
$em->persist($book);
$em->flush();
}
catch (UniqueConstraintViolationException $th) {
return $this->json('Conflic : Traget resource already exist', 409);
}
catch (ForeignKeyConstraintViolationException $th)
{
return $this->json('Author not found', 400);
}
return $this->json(null, 201, array('Location' => '/book/' . $book->getId()));
}
Lors de la désérialisation du json reçu via la requête, nous avons un objet supplémentaire se trouvant dans l’attribut “author”. Cet objet ne contient que l’id de l’auteur. Nous allons donc devoir remplacer le contenu de “author” par quelque chose de reconnu et valide.
Nous utilisons la notion de référence d’entité via “getReference” qui nous évite de devoir charger inutilement une entité.
Dans cet extrait, nous utilisons une autre méthode pour sauvegarder notre entité en passant directement par l’entityManager. Nous ajoutons une gestion sommaire des erreurs lorsque l’id auteur n’existe pas ou que la ressource existe déjà.
2- Route pour obtenir le détail d’un livre
GET http://localhost/book/{bookId}
#[Route('/book/{bookId}', name: 'book_by_id', methods: ['GET'])]
public function getBookById(SerializerInterface $serializer, BookRepository $bookRepository, int $bookId): JsonResponse
{
if (($book = $bookRepository->findOneById($bookId)) === null)
return $this->json(null, 404);
$serializedBook = $serializer->serialize($book, 'json', [
'groups'=> ['bookAuthor']
]);
return JsonResponse::fromJsonString($serializedBook, 200);
}
Il s’agit d’une variante de ce que nous avons vu avec la route “/authors”. Pour éviter la référence circulaire, nous définissons des groupes dans les entités. Si nous ne mettons pas le groupe sur la relation entité dans l’enfant, la sérialisation ignorera l’attribut.
Dans l’entité “Author”, nous ajoutons l’annotation “#[Groups([‘bookAuthor’])]” au dessus des attributs “id”, “firstname” et “lastname”.
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bookAuthor'])]
private ?int $id = null;
#[ORM\Column(length: 32)]
#[Groups(['bookAuthor'])]
private ?string $firstname = null;
#[ORM\Column(length: 32)]
#[Groups(['bookAuthor'])]
private ?string $lastname = null;
Dans l’entité “Book”, nous ajoutons l’annotation “#[Groups([‘bookAuthor’])]” au dessus de tous les attributs.
3- Route pour lister les livres
Si nous souhaitons que les informations du premier niveau (sans obtenir d’information sur l’auteur), nous utiliserons par exemple un group “book” qui ne sera pas ajouté sur la relation “author”.
Nous devons donc mettre à jour notre entité “Book” :
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bookAuthor', 'book'])]
private ?int $id = null;
#[ORM\Column(length: 128)]
#[Groups(['bookAuthor', 'book'])]
private ?string $title = null;
#[ORM\Column(type: Types::DATE_MUTABLE)]
#[Groups(['bookAuthor', 'book'])]
private ?\DateTimeInterface $publishedDate = null;
#[ORM\ManyToOne(inversedBy: 'books')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['bookAuthor'])]
private ?Author $author = null;
Concernant le contrôleur, nous obtenons quelque chose de similaire à ce que nous avons précédemment vu :
#[Route('/books', name: 'books_all', methods: ['GET'])]
public function getBooks(SerializerInterface $serializer, BookRepository $bookRepository): JsonResponse
{
$books = $bookRepository->findAll();
$serializedBooks = $serializer->serialize($books, 'json', [
'groups'=> ['book']
]);
return JsonResponse::fromJsonString($serializedBooks, 200);
}
4- Route pour obtenir une liste par auteur
GET http://localhost/book/author/{authorId}
#[Route('/books/author/{authorId}', name: 'books_by_author', methods: ['GET'])]
public function getBooksByAuthor(SerializerInterface $serializer, BookRepository $bookRepository, int $authorId): JsonResponse
{
$books = $bookRepository->findByAuthor($authorId);
$serializedBooks = $serializer->serialize($books, 'json', [
'groups'=> ['book']
]);
return JsonResponse::fromJsonString($serializedBooks, 200);
}
Rien de nouveau, nous spécifions juste l’id de l’auteur dans la route
5- Route pour supprimer un livre
DELETE http://localhost/book/{bookId}
#[Route('/book/{bookId}', name: 'book_delete', methods: ['DELETE'])]
public function deleteBook(BookRepository $bookRepository, int $bookId): JsonResponse
{
$book = $bookRepository->findOneById($bookId);
if ($book === null)
return $this->json(null, 404);
else
$bookRepository->remove($book, true);
return $this->json(null, 204);
}
Nous suivons la même logique que pour la suppression d’un auteur.
Une fois toutes vos routes terminées, vous pouvez consulter le récapitulatif en saisissant la commande :
symfony console debug:route
Quelques idées d’amélioration en fonction de vos besoins
- Ajout de tests unitaires et fonctionnels
- Mise en place de redis sur certains appels en BDD
- Optimisation des containers
- Sécurisation de l’API (restriction par domaine / IP, bearer token, etc.)
- Réflexion sur l’architecture (modulaire, CQRS, CRUD, etc.)
Les sources
Vous pouvez consulter les sources du projet sur le repo git suivant :
https://github.com/maillotf/Library_PHP_Symfony
Merci de votre lecture ! :) Si vous avez aimé cet article, n’hésitez pas à cliquer sur le ❤ et à partager l’article autour de vous !