Cours - Api Platform - Chapitre 02 - Création des "API"
SOMMAIRE
Ajouter la classe User dans le catalogue d’API
Tester une API avec API Platform
Introduction
Dans le chapitre précédent, nous avons mis en place le projet. Dans celui-ci nous installons API Platform.
Installation d’API Platform
Dans votre projet, installer le module en tapant la commande :
composer require api
Puis le module Apache-pack :
composer require symfony/apache-pack
Do you want to execute this recipe?[y] Yes
Si
vous saisissez l’url de votre projet en y ajoutant /api, voici ce
que vous voyez :
exemple :
https://s3-4400.nuage-peda.fr/forum/public/api
Il s’agit de l’url qui vous permettra de consulter le catalogue des « API ». Pour l’instant, il est vide.
Ajouter la classe User dans le catalogue d’API
Dans l’entité « src/Entity/User.php » ajoutez les 2 lignes suivantes :
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use ApiPlatform\Core\Annotation\ApiResource;
/**
* @ORM\Entity(repositoryClass=UserRepository::class)
*/
#[ApiResource()]
class User implements UserInterface, PasswordAuthenticatedUserInterface
Voici ce que vous devez voir en rafraichissant le catalogue :
Par défaut, vous allez pouvoir réaliser 6 actions sur l’entité « User ».
Nous allons tester l’url /api/users qui utilise la méthode GET. Celle-ci retourne une collection d’USERS.
Si nous cliquons sur cette méthode, nous voyons qu’il existe un moyen simple pour la tester.
Nous avons les paramètres attendu, ici, un numéro de page :
Et voici la description du résultat au format JSON à exploiter :
En
cliquant sur « Try out » puis sur « execute »
vous pouvez tester l’API.
La documentation nous donne l’appel à l’API dans un terminal :
curl -X 'GET' \
'https://s3-4400.nuage-peda.fr/forum/public/api/users?page=1' \
-H 'accept: application/ld+json'
et l’URL à taper dans un navigateur :
https://s3-4400.nuage-peda.fr/forum/public/api/users?page=1
Nous obtenons l’erreur suivante :
Bad Request
A circular reference has been detected when serializing the object of class \"App\\Entity\\Message\" (configured limit: 1).",
Il va chercher à récupérer les messages, nous allons ajouter ceci dans l’entité « Message » :
<?php
namespace App\Entity;
use App\Repository\MessageRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
/**
* @ORM\Entity(repositoryClass=MessageRepository::class)
*/
#[ApiResource()]
Nous obtenons maintenant le résultat :
Nous remarquons que le « ROLE_USER » apparait plusieurs fois. Il faut modifier la méthode getRoles de l’entité User qui retourne ce rôle automatiquement.
Supprimer la ligne surlignée :
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
Les « Groups »
En analysant le résultat, nous remarquons que nous recevons des données qui ne sont pas appropriées comme le rôle de l’utilisateur ou le mot de passe (même s’il est haché). De plus, nous avons les « uri » (Uniform Resource Identifier) qui donne l’adresse (exemple : "/forum/public/api/messages/47") de chaque message, mais il serait pratique d’avoir d’avoir directement les données.
Les « Groups vont nous permettre d’indiquer les données que nous souhaitons récupérer.
Dans l’entité User, ajoutez les éléments suivants :
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Entity(repositoryClass=UserRepository::class)
*/
#[ApiResource(normalizationContext:['groups' => ['read']])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=180, unique=true)
*/
private $email;
/**
* @ORM\Column(type="json")
*/
private $roles = [];
/**
* @var string The hashed password
* @ORM\Column(type="string")
*/
private $password;
/**
* @ORM\Column(type="string", length=30)
*/
#[Groups(["read"])]
private $nom;
/**
* @ORM\Column(type="string", length=30)
*/
#[Groups(["read"])]
private $prenom;
/**
* @ORM\Column(type="datetime")
*/
#[Groups(["read"])]
private $dateInscription;
/**
* @ORM\OneToMany(targetEntity=Message::class, mappedBy="user", orphanRemoval=true)
*/
#[Groups(["read"])]
private $messages;
Commentaire :
En plaçant « #[Groups(["read"])] » avant un attribut, nous indiquons à API Platform que nous souhaitons intégrer cette donnée dans la réponse.
Nous mettons en place les mêmes éléments dans l’entité « Message ».
<?php
namespace App\Entity;
use App\Repository\MessageRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Entity(repositoryClass=MessageRepository::class)
*/
#[ApiResource(normalizationContext:['groups' => ['read']])]
class Message
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=50)
*/
#[Groups(["read"])]
private $titre;
/**
* @ORM\Column(type="datetime")
*/
#[Groups(["read"])]
private $datePoste;
/**
* @ORM\Column(type="text")
*/
#[Groups(["read"])]
private $contenu;
/**
* @ORM\ManyToOne(targetEntity=User::class, inversedBy="messages")
* @ORM\JoinColumn(nullable=false)
*/
private $user;
/**
* @ORM\ManyToOne(targetEntity=Message::class, inversedBy="messages")
*/
private $parent;
/**
* @ORM\OneToMany(targetEntity=Message::class, mappedBy="parent")
*/
private $messages;
Voici un extrait du résultat pour l’api « api/users » :
Commentaire :
Nous voyons maintenant les données concernant les messages directement dans la réponse.
Les opérations proposées
En activant les « ApiResources » des classes, vous activez toutes les API liées à une entité. Avec elles, vous avez toutes les fonctionnalités permettant de gérer les données. Création d’un élément (POST), modification d’un élément (PATCH), suppression d’un élément (DELETE), remplacement d’un élément (PUT), récupération d’un élément (GET) et enfin, récupération de tous les éléments (GET). Il s’agit donc d’obtenir un CRUD (Create, Read, Update, Delete) par défaut.
Il existe 2 types d’opérations, les opérations portant sur un élément : Item Opération et les opérations portant sur une collection d’éléments :
Item opérations
Voici les « item operations » proposés par défaut par API Platform « Collection Operations ».
GET |
Récupère un élément |
PUT |
Remplace un élément |
PATCH |
Modifie une partie d’un élément |
DELETE |
Supprime un élément |
Nous pouvons désactiver tous les « item operation ». Essayons dans l’entité « Message ».
<?php
namespace App\Entity;
use App\Repository\MessageRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Entity(repositoryClass=MessageRepository::class)
*/
#[ApiResource(normalizationContext:['groups' => ['read']],
itemOperations:[])]
class Message
Voici l’affichage du catalogue qui en résulte :
Remarque :
Nos « item opération » ont bien disparu.
Essayons maintenant de récupérer les utilisateurs avec l’api GET « api/users ».
Remarque :
Nous obtenons une erreur, car il ne trouve plus la route pour aller chercher chaque message.
Activons maintenant uniquement l’opération GET comme ceci,
#[ApiResource(normalizationContext:['groups' => ['read']],
itemOperations:['GET'])]
class Message
Et voici le résultat :
Remarque :
Nous avons uniquement l’« item operation » GET. Vous pouvez à nouveau récupérer l’ensemble des utilisateurs avec leurs messages.
Collection Operations
Voici les « Collection Operations » proposées par API Platform :
GET |
Retourne une collection d’éléments |
POST |
Crée un nouvel élément |
Désactivez la création d’un nouveau message en configurant l’API comme ceci (Message.php) :
#[ApiResource(normalizationContext:['groups' => ['read']],
itemOperations:['GET'],
collectionOperations:['GET'])]
class Message
Remarque :
La route POST est désactivée.
Gestion des droits d’accès
API Platform va nous permettre d’accéder à des ressources uniquement si nous en avons la permission. Nous allons donc mettre en place la sécurité dans notre projet. Nous allons commencer par générer des clés qui nous permettront de générer un « token ». Lorsque vous allez vous identifier, un « token » vous sera remis avec date d’expiration. Vous communiquerez ce « token » à chaque appel d’API et s’il est expiré, il faudra en générer un autre.
Mise en place de la sécurité
Installation du Bundle permettant de gérer les clés et la connexion via un token.
Dans le répertoire de votre projet tapez la commande :
composer req lexik/jwt-authentication-bundle
Générer les clés :
php bin/console lexik:jwt:generate-keypair
Vous trouverez dans le répertoire « config/jwt » la clé publique et la clé privée générées par la commande.
Dans le « .env », vous trouverez la configuration de vos clés :
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=2bf55c26309c7b64e84f813ebbd4be1b
###< lexik/jwt-authentication-bundle ###
Si vous utilisez le « .env.local », déplacez les lignes générées dans votre « .env . pour les mettre à l’intérieur du « env.local ». Ne prenez pas le mien, car la « PASSPHRASE » sera différente.
Et enfin, dans le fichier « config/packages/lexik_jwt_authentication.yaml », vous trouverez la configuration suivante :
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600
Remarque :
Pour les tests, je préfère donner une durée de vie d’une heure au « token ».
Nous allons maintenant déclarer la route pour s’authentifier. Allez dans le fichier « config/routes.yaml » et ajoutez les lignes suivantes :
authentication_token:
path: /api/authentication_token
methods: ['POST']
Remarque :
La route aura l’url « authentication_token » et il faudra s’y rendre avec la méthode « POST ». Ainsi pas question de donner les identifiants dans l’url.
Vous pouvez obtenir la liste des routes créées dans votre projet en tapant la commande :
php bin/console debug:router
Mise en place du firewall dans le fichier
« config/packages/security.yaml ».
security:
enable_authenticator_manager: true
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
App\Entity\User:
algorithm: auto
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
provider: app_user_provider
pattern: ^/api
stateless: true
json_login:
username_path: email
check_path: /api/authentication_token
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
jwt: ~
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
Remarques :
« stateless = true » signifie que nous ne gardons pas les données dans une session, ce qui est logique puisque les API sont consommées par des applications non hébergées sur le serveur.
« check_path: » est la propriété dans laquelle nous mettons la route pour s’authentifier.
« authenticators:
« jwt: ~ » est le nom du mécanisme qui va vérifier les identifiants et attribuer un token. Nous donnons celui que nous avons installé avec le bundle.
Sécuriser une API
Nous allons sécuriser la modification des données d’un utilisateur. Un utilisateur pourra modifier ses données, mais pas celles des autres. Nous allons donner à l’administrateur le droit de modifier les données de n’importe quel utilisateur.
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Entity(repositoryClass=UserRepository::class)
*/
#[ApiResource(normalizationContext:['groups' => ['read']],
itemOperations: ["get", "patch"=>["security"=>"is_granted('ROLE_ADMIN') or object == user"]]
)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
Remarques :
« security"=>"is_granted('ROLE_ADMIN') or object == user » signifie que la personne doit être authentifiée en tant qu’administrateur ou que l’utilisateur connecté soit le même que l’utilisateur récupéré avec l’API.
Ici, nous protégeons la méthode « PATCH » qui permet de modifier un élément (ici un utilisateur).
Pour tester cette commande, je vais récupérer un utilisateur de la base de données, et je vais essayer de modifier son adresse email :
Son identifiant est le 161 et son email est « lucie.berthelot@noos.fr ». Je vais tenter de modifier son adresse en « lucie.berthelot@nuage-pedagogique.fr » en utilisant l’API PATCH suivante :
Nous obtenons une erreur 401, JWT Token not found. C’est logique puisque nous ne sommes pas connectés.
Tester une API avec API Platform
Activer la route d’authentification
Dans le répertoire « src », créez le répertoire « OpenApi » et créez le fichier « JwtDecorator.php » à l ‘intérieur :
<?php
declare(strict_types=1);
namespace App\OpenApi;
use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\Core\OpenApi\OpenApi;
use ApiPlatform\Core\OpenApi\Model;
final class JwtDecorator implements OpenApiFactoryInterface
{
public function __construct(
private OpenApiFactoryInterface $decorated
) {}
public function __invoke(array $context = []): OpenApi
{
$openApi = ($this->decorated)($context);
$schemas = $openApi->getComponents()->getSchemas();
$schemas['Token'] = new \ArrayObject([
'type' => 'object',
'properties' => [
'token' => [
'type' => 'string',
'readOnly' => true,
],
],
]);
$schemas['Credentials'] = new \ArrayObject([
'type' => 'object',
'properties' => [
'email' => [
'type' => 'string',
'example' => 'compte@email.com',
],
'password' => [
'type' => 'string',
'example' => 'mot de passe',
],
],
]);
$pathItem = new Model\PathItem(
ref: 'JWT Token',
post: new Model\Operation(
operationId: 'postCredentialsItem',
tags: ['Token'],
responses: [
'200' => [
'description' => 'Get JWT token',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/Token',
],
],
],
],
],
summary: 'Get JWT token to login.',
requestBody: new Model\RequestBody(
description: 'Generate new JWT Token',
content: new \ArrayObject([
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/Credentials',
],
],
]),
),
),
);
$openApi->getPaths()->addPath('/api/authentication_token', $pathItem);
return $openApi;
}
}
Remarque :
Je ne rentrerai pas dans les détails de ce code dans ce chapitre. Cela m’importe peu pour l’instant. Les parties surlignées vous permettent de mettre des valeurs par défaut.
Dans « config/services.yaml » ajoutez :
App\OpenApi\JwtDecorator:
decorates: 'api_platform.openapi.factory'
autoconfigure: false
Vous pouvez maintenant tester en modifiant les valeurs « username » avec l’email et « password » avec le mot de passe d’un de vos utilisateurs. Exemple :
Et voici la réponse comprenant le « token ».
Saisi du token
Il faudra saisir le « token » dans API Platform afin de tester celles qui sont protégées.
Ajouter dans le fichier « config/packages/api_platform.yaml » :
api_platform:
mapping:
paths: ['%kernel.project_dir%/src/Entity']
patch_formats:
json: ['application/merge-patch+json']
swagger:
api_keys:
apiKey:
name: Authorization
type: header
En cliquant sur « Authorize », voici ce que vous obtenez ;
Je saisis maintenant mon token à l’intérieur de cette façon :
Prendre le token et le mettre dans « authorize » avec le mot Bearer suivi du token sans les guillemets
Je recommence un test pour modifier les données suivantes :
Identifiant = 164 et email = «alexandria.duhamel@free.fr ». Je vais tenter de modifier son adresse en « alexandria.duhamel@nuage-pedagogique.fr » en utilisant l’API PATCH suivante :
Le code de la réponse doit être de 200 si tout va bien.