· 12 min read

Intégration continue d'une API (Symfony/API Platform) avec Postman et GitLab CI

Aujourd'hui nous allons voir comment mettre en place une intégration continue pour une API Symfony (avec API Platform), en utilisant Postman pour écrire nos tests, et GitLab CI pour les exécuter.

Aujourd'hui nous allons voir comment mettre en place une intégration continue pour une API Symfony (avec API Platform), en utilisant Postman pour écrire nos tests, et GitLab CI pour les exécuter.

Disclamer

Cet article est long, très long ! Le café est obligatoire pour une lecture dans de bonnes conditions ;-).
Bonne chance !

Introduction

L’idée aujourd’hui c’est de partir de zéro, de construire notre API de démonstration (avec Symfony et API Platform), d’écrire nos tests dans Postman, puis de les automatiser dans une pipeline d’intégration continue GitLab !

Cet article vient en complément de ce premier article qui évoque en détail la mise en œuvre d’un pipeline d’intégration continue de projets Symfony.

Voici comment nous allons procéder :

  • Création de l’API avec Symfony et API Platform
  • Protection de notre API à l’aide d’un token JWT avec LexikJWTAuthenticationBundle
  • Gestion du hash des mots de passe via un Event Subscriber
  • Création du dépôt GitLab
  • Écriture des tests automatisés dans Postman
  • Exécution des tests en ligne de commande avec Newman
  • Mise en place d’un pipeline GitLab exécutant les tests avec Newman

Création de l’API avec Symfony et API Platform

Créons notre application Symfony.

symfony new testapi
cd testapi

Installons API Platform.

composer req api

Modifions-le .env pour utiliser une base SQLite (pour la démo c’est plus simple ;-) ).

###> doctrine/doctrine-bundle ###
#
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"
# DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=13&charset=utf8"
###< doctrine/doctrine-bundle ###

Créons la base de données.

symfony console d:d:c

Pour notre exemple, nous allons avoir besoin d’utilisateurs, créons donc des utilisateurs dans notres application Symfony. Pour cela, il nous faut également installer le Maker Bundle.

composer require symfony/maker-bundle --dev

symfony console make:user User

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 > yes

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > email

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 > yes

N’oublions pas de générer et appliquer les migrations.

symfony console make:migration
symfony console d:m:m

Pour notre exemple, nous allons mettre en place une entité “Demo”, avec quelques champs, histoire d’avoir de quoi tester plus tard !

symfony console  make:entity Demo
New property name (press <return> to stop adding fields):
 > number

 Field type (enter ? to see all types) [string]:
 > integer

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Demo.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > name

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 255

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Demo.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > description

 Field type (enter ? to see all types) [string]:
 > text

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Demo.php

N’oublions pas de générer et appliquer les migrations (encore !)

symfony console make:migration
symfony console d:m:m

”Exposons” nos entités User et Demo via notre API, en y ajoutant les annotations pour API Platform.

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Entity(repositoryClass=UserRepository::class)
 * @ApiResource
 */
class User implements UserInterface
{
  ...
}

<?php

namespace App\Entity;

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

/**
 * @ORM\Entity(repositoryClass=DemoRepository::class)
 * @ApiResource
 */
class Demo
{
  ...
}

Vérifions que notre API fonctionne ! Lançons-le serveur interne de Symfony.

symfony serve -d

Et ouvrons l’url de notre API dans un navigateur https://127.0.0.1:8000

Protection de notre API à l’aide d’un token JWT

Actuellement notre AOI est ouverte aux quatre vents, nous allons donc y ajouter une protection par token JWT.

Installons lexik/jwt-authentication-bundle.

composer require lexik/jwt-authentication-bundle

Générons les clés SSL, en utilisant en passphrase la valeur présente dans notre fichier .env (JWT_PASSPHRASE).

mkdir -p config/jwt
openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096
openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout

Nous allons paramétrer la protection de notre API en spécifiant le fonctionnement dans le fichier /config/packages/security.yaml, notamment pour protéger certaines ressources par un token, pour mettre en place l’identification (pour obtenir un token), et l’inscription d’un nouveau utilisateur.

security:
    encoders:
        App\Entity\User:
            algorithm: auto

    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    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

        registration:
            pattern: ^/api/users
            anonymous: true
            stateless: true
            methods: [POST]

        login:
            pattern:  ^/api/login
            stateless: true
            anonymous: true
            json_login:
                check_path:               /api/login_check
                success_handler:          lexik_jwt_authentication.handler.authentication_success
                failure_handler:          lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern:   ^/api
            anonymous: true
            stateless: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

        main:
            anonymous: true
            lazy: true
            provider: app_user_provider

            # activate different ways to authenticate
            # https://symfony.com/doc/current/security.html#firewalls-authentication

            # 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: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/api/users, roles: IS_AUTHENTICATED_FULLY, methods: [GET, PUT, DELETE, PATCH] }
        - { path: ^/api/demo, roles: IS_AUTHENTICATED_FULLY }

Pour que tout cela fonctionne, il faut également déclarer la route permettant l’authentification dans le fichier /config/routes.yaml.

#index:
#    path: /
#    controller: App\Controller\DefaultController::index
api_login_check:
    path: /api/login_check

Vérifions que notre API est bien protégée par un token JWT avec Postman, par exemple avec une requête de type GET sur /api/demos.

Si tout ce passe bien, vous devriez obtenir une réponse avec le message “JWT token not found”, c’est bon signe ;-) .

Essayons de créer un User, en envoyant une requête POST sur /api/users avec le “body” suivant au format JSON.

{
  "email": "test@test.com",
  "password": "password"
}

L’opération fonctionne bien, nous bien créer un utilisateur, mais, gros problème, en l’état son mot de passe est en clair (pas bien !), nous allons voir dans la prochaine section comment hasher le mot de passe lors d’une création.

Gestion du hash des mots de passe via un Event Subscriber

Nous allons mettre en place un Event Subscriber, qui va utiliser les événements émis par API Platform, en particulier avant de faire persister une donnée, ici un User (d’ailleurs on met en place un test qui s’assurer qu’il s’agit bien d’une instance de User).

Créons un répertoire Events dans /src.

mkdir src/Events

Et dans ce répertoire, créons un fichier PasswordEncoderSubscriber.php.

<?php

namespace App\Events;

use ApiPlatform\Core\EventListener\EventPriorities;
use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class PasswordEncoderSubscriber implements EventSubscriberInterface
{
    /**
     * @var UserPasswordEncoderInterface
     */
    private $encoder;

    public function __construct(UserPasswordEncoderInterface $encoder)
    {
        $this->encoder = $encoder;
    }

    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::VIEW => ['encodePassword', EventPriorities::PRE_WRITE]
        ];
    }

    public function encodePassword(ViewEvent $event)
    {
        $result = $event->getControllerResult();
        $method = $event->getRequest()->getMethod();

        if ($result instanceof User && $method === "POST") {
            $hash = $this->encoder->encodePassword($result, $result->getPassword());
            $result->setPassword($hash);
        }
    }
}

Essayons de créer un User, en envoyant une requête POST sur /api/users avec le “body” suivant au format JSON.

{
  "email": "test2@test.com",
  "password": "password"
}

Parfait, cette fois-ci le mot de passe est bien hashé ! Essayons d’obtenir un Token JWT avec une requet POST sur /api/login_check avec le body au format JSON suivant :

{
  "username": "test2@test.com",
  "password": "password"
}

Impeccable, nous obtenons bien un token JWT !

Création du dépôt GitLab

Il est grand temps de créer un nouveau dépôt GitLab pour notre projet ! Aujourd’hui nous allons le faire directement en ligne de commande pour gagner du temps !

git push --set-upstream git@gitlab.com:yoandev.co/testapi.git master

Écriture des tests automatisés dans Postman

Maintenant que nous avons une API nous allons pouvoir mettre en place nos tests automatisés avec Postman. Et quand je parle de tests automatisés, je parle de dérouler un scénario complet qui viendra tester tout ou partie de l’API.

Aujourd’hui nous allons tester le scénario suivant :

  • Créer un utilisateur
  • Tenter de créer une 2ᵉ fois le même utilisateur et vérifier que cela échoue
  • Ouvrir une session avec cet utilisateur et obtenir un token JWT
  • Créer une entrée “Demo”
  • Modifier cette entrée “Demo”
  • Supprimer cette entrée “Demo”

Préparons l’environnement Postman

Commençons par créer une collection “TEST API”.

Créons également un environnement dédié à notre API “TEST API ENV”, et ajoutons-y tout de suite une variable d’environnement (que nous nommons “url”) contenant l’url de votre API (celle fournie par votre serveur Symfony).

Puis, pensez à bien “basculer” dans ce nouvel environnement.

Créer un utilisateur

Créer une requête dans votre collection, pour la démo nous l’appellerons “should create a user”.

Écrivons donc notre requête de type POST sur /api/users, avec dans “Body” le JSON suivant :

{
    "email": "{{$randomEmail}}",
    "password": "password"
}

Le $randomEmail permet d’obtenir une adresse mail aléatoire. Cool non !?

Soumettons-la requête et vérifions que tout fonctionne.

Pour la suite, nous allons avoir besoin de re-utiliser cet utilisateur (pour tester l’échec le créer en double, et pour s’authentifier). Ajoutons donc dans la partie “Tests” un bout de code pour récupérer notre Utilisateur et le stocker dans une variable de notre environnement (“username”), et ajoutons au passage un test qui check le “status” de la réponse, ici il doit être de type 201.

pm.environment.set("username", pm.response.json().username);

pm.test("Status test", function () {
    pm.response.to.have.status(201);
});

Soumettons à nouveau cette requête, vous pouvez constater que le test réussi, et qu’une variable “username” est bien présente dans votre environnement !

Tenter de créer une 2ᵉ fois le même utilisateur et vérifier que cela échoue

Dupliquons cette requête sous le nom “Should not create a user”, réutilisons la même adresse mail que précédemment en appelant notre variable dans le JSON que l’on envoie.

{
    "email": "{{username}}",
    "password": "password"
}

Modifions aussi notre test, pour qu’il vérifie que l’on obtient un code d’erreur 500.

pm.test("Status test", function () {
    pm.response.to.have.status(500);
});

Ouvrir une session avec cet utilisateur et obtenir un token JWT

Créons une nouvelle requête “Should generate us a token” de type POST sur /api/login_check, avec le JSON suivant dans le body.

{
  "username": "{{username}}",
  "password": "password"
}

Et exécutons-la requête.

Ajoutons-y un test, et stockons le token dans une variable en ajoutant ce bout de code dans l’onglet test.

pm.environment.set("token", pm.response.json().token);

pm.test("Status test", function () {
    pm.response.to.have.status(200);
});

Créer une entrée “Demo”

Créons une requête “should create a demo”, de type POST sur /api/demos. Il nous faut ici utiliser notre Token JWT, pour cela ajoutons le (via la variable d’environnement token dans l’onglet Authorization et Bearer Token.

Passons-lui également les données attendue dans le body.

{
  "number": {{$randomInt}},
  "name": "Texte par defaut",
  "description": "Texte par defaut"
}

Écrivons notre test, et stockons-le “id” de cette ressource (nous allons en avoir besoin juste après !).

pm.environment.set("lastDemo", pm.response.json()['id']);

pm.test("Status test", function () {
    pm.response.to.have.status(201);
});

Modifier cette entrée “Demo”

Créons (encore !) une nouvelle requête “should edit a demo” de type PUT sur l’url urlapi/demos/lastDemo (merci les variables d’environnement !). N’oubliez pas également le Bearer Token ;-).

Pour le “body”, voici le JSON.

{
    "number": 999,
    "name": "Modification",
    "description": "Modification"
}

Cotés tests, nous allons vérifier le code de status (200), et vérifier que la valeur du champ “Name” est bien mise àjout !

pm.test("Status test", function () {
    pm.response.to.have.status(200);
});

pm.test("Update Name", function() {
    pm.expect(pm.response.json()['name']).to.eql("Modification");
});

Supprimer cette entrée “Demo”

Ultime test, la suppression de notre entrée “demo” ! Logiquement vous devriez maintenant maitriser le processus ;-)

Nous créons donc une requête “should remove a demo”, de type DELETE sur l’url urlapi/demos/lastDemo. Pour les tests nous vérifions que le code de status soit bien 204.

pm.test("Status test", function () {
    pm.response.to.have.status(204);
});

Ajout de l’environnement Postman et de la collection de test dans le projet

Histoire de terminer cette longue écriture de tests, exportons la collection et l’environnement (ce sont des fichiers JSON) dans un répertoire /postman à la racine de notre projet Symfony, et commitons sur le dépôt GitLab.

git add .
git commit -m "ecriture de tests postman"
git push

Exécution des tests en ligne de commande avec Newman

Il existe une version ligne de commande pour Postman qui se nomme Newman. L’idée c’est de jouer de manière automatique l’ensemble de nos tests (Création d’un user, récupérer un token, création d’une “démo” etc …) dans un shell (pour ensuite intégré cela dans un pipeline d’intégration continue).

Pour installer Newman vous devez disposer de NPM sur votre machine, l’installation de Newman est très simple :

sudo npm install -g newman

Une fois Newman installé, il ne reste plus qu’a jouer nos tests, avec la commande suivante et admirer le résultat (c’est beau n’est-ce pas ?) :

newman run ./postman/postman_collection.json -e ./postman/postman_environment.json

Mise en place d’un pipeline GitLab exécutant les tests avec Newman

Avant de débuter ce chapitre, vous devriez jeter un coup d’œil à cet article : Intégration continue d’un projet Symfony 5 avec GitLab CI.

Commençons par créer (et versionner dans le dépôt GIT) nos clés publique et privée dédiées aux tests (obligatoire pour générer un token JWT).

openssl genpkey -out config/jwt/private_test.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096 openssl pkey -in config/jwt/private_test.pem -out config/jwt/public_test.pem -pubout

Modifions-le .gitigore pour permettre de versionner les clés de test (tout en excluant les clés de production !!).

###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###

###> lexik/jwt-authentication-bundle ###
/config/jwt/public.pem
/config/jwt/private.pem
###< lexik/jwt-authentication-bundle ###

Créons un fichier de pipeline .gitlab-ci.yml à la racine de notre projet Symfony qui va réaliser les actions suivantes :

  • Démarrer une image Docker php:7.4-cli
  • installer Git, Zip, NPM et wget
  • Installer Composer
  • Activer l’extension php ZIP
  • Installer la CLI de Symfony et la déplacer pour un usage global
  • Installer Newman
  • Jouer le “composer install”
  • Renommer les clés de test pour qu’elles correspondent à ce qui est attendu dans le fichier .env
  • Installer les certificats et activer le serveur interne Symfony
  • Créer la base de données et jouer les migrations
  • Enfin : Exécuter les tests de notre API avec Newman !
image: php:7.4-cli

before_script:
    - apt-get update && apt-get install -y git libzip-dev npm wget
    - curl -sSk https://getcomposer.org/installer | php -- --disable-tls && mv composer.phar /usr/local/bin/composer
    - docker-php-ext-install zip
    - wget https://get.symfony.com/cli/installer -O - | bash
    - mv /root/.symfony/bin/symfony /usr/local/bin/symfony
    - npm install -g newman
    - composer install
    - mv ./config/jwt/private_test.pem ./config/jwt/private.pem && mv ./config/jwt/public_test.pem ./config/jwt/public.pem 
    - symfony server:ca:install && symfony serve -d
    - php bin/console doctrine:database:create --env=test
    - php bin/console doctrine:migration:migrate --env=test --no-interaction

cache:
    paths:
        - vendor/

stages:
    - TestAPI

Newman:
    stage: TestAPI
    script:
        - newman run ./postman/postman_collection.json -e ./postman/postman_environment.json 
    allow_failure: false

Il ne nous reste plus qu’à faire un commit, et regarder le pipeline s’exécuter : bonheur absolu !

Conclusions et dépôt GitLab

Dans ce (trop) long article nous avons étudié ensemble le développement d’une API (très minimaliste) avec Symfony et API Platform, l’écriture de tests et leurs intégrations dans un pipeline de GitLab CI.

Avec ces quelques bases posées ici, qui ne sont qu’une approche possible parmi d’autres, vous disposez d’une méthodologie vous permettant de tester fonctionnellement et de manière automatique vos API, ce qui devrait grandement (ça dépend de la qualité des tests bien sur) limiter l’apparition de bugs ou de régressions.

L’ensemble des sources du projet sont disponibles sur ce dépôt GitLab.

Back to Blog