SOMMAIRE


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.



Voir le Chapitre 3