Mettre en place une API REST 1ère partie

De plus en plus, on assiste à une demande de développement d’API REST pour communiquer avec les mobiles, ou pour communiquer entre le front et le back.

S’il est assez simple d’en coder une avec Symfony2 grâce au CRUD en renvoyant un résultat en json, ce n’est pas très souple, la validation des données doit se faire dans le contrôleur, et on doit indiquer le code d’erreur à renvoyer… heureusement pour nous, il y a FosRESTBundle pour nous aider!

Notre entité Category

Nous commençons par créer une entité catégorie simple, que nous exposerons via une API REST:

<?php

namespace App\CoreBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Category
 *
 * @ORM\Table(name="category")
 * @ORM\Entity(repositoryClass="App\CoreBundle\Repository\CategoryRepository")
 */
class Category
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="title", type="string", length=255)
     * @Assert\NotBlank
     * @Assert\Length(min=2, max=100)
     * @Assert\Regex("/^[a-zA-ZáàâäãåçéèêëíìîïñóòôöõúùûüýÿæœÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸÆŒ._\s-]+$/")
     */
    private $title;

    /**
     * @Gedmo\Slug(fields={"title"})
     * @ORM\Column(length=255, unique=true)
     */
    private $slug;

    /**
     * @param string $title
     * @param string $slug
     */
    public function __construct($title)
    {
        $this->title = $title;
    }

    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set title
     *
     * @param string $title
     *
     * @return Category
     */
    public function setTitle($title)
    {
        $this->title = $title;

        return $this;
    }

    /**
     * Get title
     *
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * Set slug
     *
     * @param string $slug
     *
     * @return Category
     */
    public function setSlug($slug)
    {
        $this->slug = $slug;

        return $this;
    }

    /**
     * Get slug
     *
     * @return string
     */
    public function getSlug()
    {
        return $this->slug;
    }
}

Je ne présente pas l’extension de stofDoctrineExtensions qui permet de générer un slug à la volée (entre autres, car de nombreux comportements sont possibles avec ce bundle, notamment createdAt et updatedAt).

Le composer.json

Notre entité créée et générée en base avec la commande doctrine:schema:update, nous pouvons installer FosRestBundle, JMS Serializer, Nelmio Doc, pagerFanta et le jwtAuthenticationBundle de Lexik:

"friendsofsymfony/rest-bundle": "~1.5",
"jms/serializer-bundle": "~0.13",
"nelmio/api-doc-bundle": "~2.8",
"pagerfanta/pagerfanta": "~1.0",
"lexik/jwt-authentication-bundle": "dev-master"

AppKernel.php

N’oublions pas, dans le fichier AppKernel.php, de déclarer nos bundles:

new FOS\RestBundle\FOSRestBundle(),
new Nelmio\ApiDocBundle\NelmioApiDocBundle(),
new JMS\SerializerBundle\JMSSerializerBundle(),
new Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle(),

Chaque bundle va nous être utile:

  • FosRestBundle pour mettre en place notre interface REST
  • Nelmio pour l’interface de test de notre API
  • JMS Serializer pour sérialiser les données en json ou xml
  • JWTAuthenticationBundle pour sécuriser notre API par un token

Configuration des différents bundles dans config.yml

fos_rest:
    body_listener:
        # Convert underscore case properties to camel case
        # ie: { "the_date": "2014-09-30" } => { "theDate": "2014-09-30" }
        array_normalizer: fos_rest.normalizer.camel_keys
    body_converter:
        enabled: true
        validate: true
        validation_errors_argument: violations
    view:
        view_response_listener: 'force'
        mime_types:
            json:
                - application/json
                - application/x-json
                - application/vnd.app.categories+json
                - application/vnd.app.categories+json;v=1.0
                - application/vnd.app.categories+json;v=2.0
            xml:
                - text/xml
                - application/vnd.app.categories+xml
                - application/vnd.app.categories+xml;v=1.0
                - application/vnd.app.categories+xml;v=2.0
        formats:
            json: true
            xml: true
            rss: false
    exception:
        codes:
            'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
            'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
        messages:
            'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
    param_fetcher_listener: true
    serializer:
        serialize_null: true
    format_listener:
        media_type:
            version_regex: '/(v|version)=(?P<version>[0-9\.]+)/'
        rules:
            - { path: '^/api', priorities: ['json', 'xml'], fallback_format: json, prefer_extension: false }
            - { path: '^/', priorities: [ 'html'], fallback_format: html }

nelmio_api_doc:
    name:                 'App API documentation'
    sandbox:
        body_format:
            formats: [form, json, xml]
            default_format: json
            
# Lexik JWT Authentication
lexik_jwt_authentication:
    private_key_path:   %private_key_path%
    public_key_path:    %public_key_path%
    pass_phrase:        %pass_phrase%
    token_ttl:          %token_ttl%

Il y a là pas mal de configuration. Celle de FOS est vraiment bien fournie. Je vous invite à la regarder pour comprendre tous les paramètres. Vous verrez, il n’y a rien de compliqué.

Par contre, à présent que nous avons déclaré des variables pour le lexik Authentication Bundle, il faut les renseigner dans le fichier parameters.yml:

private_key_path: '%kernel.root_dir%/var/jwt/private.pem'
public_key_path: '%kernel.root_dir%/var/jwt/public.pem'
pass_phrase: ThisTokenIsNotSoSecretChangeItPlease
token_ttl: 86400

Je vous laisse bien sûr mettre la pass phrase que vous désirez. Les clefs privée et publique sont donc à placer dans le répertoire app/var/jwt/

Le routing

Pour finir, il faut déclarer dans le fichier routing.yml la route de la doc générée par nelmio:

nelmio_api_doc:
    resource: "@NelmioApiDocBundle/Resources/config/routing.yml"
    prefix:   /blog-api/doc

Ainsi que la route de connexion du bundle de lexik pour obtenir le token:

api_login_check:
   path: /api/login_check

Il ne reste plus qu’une chose à faire, rajouter une règle dans le vhost:

RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]

Ouf, tout est configuré, nous pouvons commencer à mettre en place le premier bundle, celui de l’authentification.

Juste avant de continuer, vous pourriez vous demander: pourquoi blog-api et non api pour le bundle nelmio? En fait, le bundle lexik d’authentification que nous allons configurer nous oblige à faire de la sorte, car il sécurise tout appel qui commence par /api, et rajouter une règle dans l’access_control n’y fait rien. Si l’on veut définir l’url api/doc, on pourra accéder à cette documentation en ligne via postman en lui passant le token d’authentification.

Le lexik Authentication Bundle

J’ai personnellement utilisé la sandbox disponible ici: https://github.com/slashfan/LexikJWTAuthenticationBundleSandbox

Ceci n’est pas du tout obligatoire. En l’état actuel des choses, vous pouvez déjà, avec Postman ou Advanced Rest Client, appeler en POST l’url http://rest-behat.local/app_dev.php/api/login_check, et vous obtiendrez quelque chose comme ça:

{
    "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXUyJ9.eyJleHAiOjE0NDQ0MDQyNzAsInVzZXJuYW1lIjoidXNlciIsImlhdCI6IjE0NDQzMTc4NzAifQ.QYrgpAF0bysGoDhvvlQ1383ilUcbF_PX6jtKgeSbSu_0mbE56q9fOXnRjqCA_3OJVn_jzWfSIosuhfjSYLBn2oWDorJkl3hhRpGvmXprae6dDWUCgDlhiJ3wph6xRIryVaRaAP0qku0Oem33kLAMWlZSJsn4kMsuwklBzdcVF1Ooi-A7RDX_mDCj-pnbqfPsWFXE52Eit1GY8OL4lf_EDluJWwUyoA-YY_VVUqaSmBN0rVzNVdhrYD5s--8fBrNwYr7EFO4fGG3QUL2c_gWLexwpfl94k6bkEvqsKaFtl5H6UqXSXaU_Hc4TrLFAsiOV04y-fx1dgtmP6QknQEP78SIzWSU1uUw_qvVDmz8L69FvWfsjFCBimn1zbXp40kKPF5y-DRfjIipqzD5AdHWVDQaiXbx5Us0rfBr0yg8WscOHhW0-Gpc7ZgKv-P-B36QtvleEtf4Ais5ohzm0it_AyjHsTQKcTa58l6tBdWznGC7Zg2v632ZNL7KW1b6T6AMULh7PCi8RI9BCfmVN9z4hk9f0o7Y4zrtJ1WdIaRzQEn-1tACiuUzzbp87fQwFTDV2ZFKUMfMFaVkEn0eaYfRxErOb6IRXEPBvrBEPTD1Xn5pzGIxrZsqSZ4EsrK7ItpB5dzOExvSTqmMKDb-HPBSr70q1XU9DI8RWqz6VTpvZPLQ"
}

Ensuite, lorsque nous allons faire nos appels dans l’API, il faudra placer dans le header « Authorization Bearer le_token_genere » pour que la requête soit acceptée. Sinon, elle sera automatiquement refusée.

En utilisant la sandbox, on voit comment on peut surcharger ce bundle avec des listeners pour avoir des retours différents, plus fournis.

Par exemple, dans mon repo github, en reprenant la sandbox, j’ai un listener qui est surchargé:

<?php

namespace App\ApiAuthenticationBundle\EventListener;

use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * JWTResponseListener
 *
 * @author Nicolas Cabot <n.cabot@lexik.fr>
 */
class JWTResponseListener
{
    /**
     * Add public data to the authentication response
     *
     * @param AuthenticationSuccessEvent $event
     */
    public function onAuthenticationSuccessResponse(AuthenticationSuccessEvent $event)
    {
        $data = $event->getData();
        $user = $event->getUser();

        if (!$user instanceof UserInterface) {
            return;
        }

        $data['data'] = array(
            'username' => $user->getUsername(),
            'roles'    => $user->getRoles()
        );

        $event->setData($data);
    }
}

Du coup, en appelant la même url, on a ce retour là:

{
    "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXUyJ9.eyJleHAiOjE0NDQ0MDQ4MzgsInVzZXJuYW1lIjoidXNlciIsImlwIjoiMTI3LjAuMC4xIiwiaWF0IjoiMTQ0NDMxODQzOCJ9.C0fv9Mvlo3MJKGGhuKMMoYfUJ2wHXuSYqpINKFBfS7cmiHVBSOUMrFFUrmAxdnh-7lqbTJksIvQyNiMY2BPhmtx8IjRSSoP82kcOHB_PbSFNE0k-A4ogyY7Iqhf-1pdiwFylF9jJyuh73raqXGHwH14bMri3f-ID3lPMRbFfon5bqdBa-vRyrDGRMDVBcLvU-Vq4RLJLcLLUtoJR1v5HbxWs8bJLtURwKWt6fWXgU67mFe6Ps2zOGXpTkMTtAY7j6aUfL4O70Wl_3lTzb1SRVN3pmktriPOWzID3dc7KgDBdmO4puQg60SqLFeXuU5MNBguAtV4K2nxP0tTOKKJ_iENAHn5DLxniHhgtuQ-md2IiEUScWL6zRA_xsMXcvK3VKVUO1h0uFg6JNmghLfN36uwOEoRKDKbS8TsILrpUCSOwTNJN9HgzfrupbP1JVYJHPEemZZsVVDjJSRp9WnjmzJ1ejF_F5nSWoDDRN6cPxJtv-yYmqcWspNe5iGE39zsXlcZLyCribDf3jLFbf9I-KorTFpLnidXsva57FaOEDeRTNfHt-5Y-qmWdx0S8swLLY94llaJLxGWTHSMT9H-ye_dbjh9i8vSO3hDVNJaIxIcgeP2v3WTfZEwTEI0pfFkUvBhWPJUkrcZonvg_l4fEYnO6it0OBNDka4jWwyhPmZA",
    "data": {
        "username": "user",
        "roles": [
            "ROLE_USER"
        ]
    }
}

C’est toute la beauté de la surcharge  😀

Créons notre API

C’est parti, nous savons que notre API est protégée, à présent, créons notre bundle ApiBundle dont le préfixe des routes sera API, et écrivons la fonction getCategories du contrôleur qui permet de renvoyer toutes les catégories.

<?php

namespace App\ApiBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Controller\Annotations as Rest;
use Nelmio\ApiDocBundle\Annotation as Doc;
use App\CoreBundle\Entity\Category;

/**
 *  @Route("/categories") 
 */
class CategoryController extends FOSRestController
{

    /**
     * @Rest\Get("/", name="app_api_categories")
     *
     * @Doc\ApiDoc(
     *     section="Categories",
     *     resource=true,
     *     description="Get the list of all categories.",
     *     statusCodes={
     *          200="Returned when successful",
     *     }
     * )
     */
    public function getCategoriesAction()
    {
        return $this->get('app_core.repository.category')->getCategories();
    }
}
  • Mon contrôleur étend le FosRestController
  • Je déclare ma route avec @Rest\Get (Rest vient des use en haut du fichier PHP)
  • J’indique à Nelmio le nom de la section, sa description, le code de retour si tout se passe bien.

Bien évidemment, getCategories n’est pas une fonction existante. Vous la trouverez dans le CategoryRepository:

<?php

namespace App\CoreBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;

/**
 * CategoryRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class CategoryRepository extends EntityRepository
{
    
    /**
     * Returns all categories ordered by title ascendant.
     *
     * @return \App\CoreBundle\Entity\category
     */
    public function getCategories()
    {
        return $this->findBy([], [ 'title' => 'ASC' ]);
    }
}

A présent, si vous appelez l’url /blog-api, et que vous rentrez le token généré précédemment dans les headers de la sandbox, voici ce que vous obtenez en cliquant sur « try »:

api get categories

Mais ce n’est pas tout, vous ne voulez peut-être pas avoir de format json mais xml.

Qu’à cela ne tienne, il suffit de rajouter un nouveau header: Accept text/xml

Et vous aurez votre retour en xml! Etonnamment, cela ne fonctionne pas dans la sandbox. Pour avoir le retour en xml, vous devez mettre _format=xml dans la fenêtre Content.

Je vous mets la copie d’écran de la requête dans POSTMan REST Client

postman_categories

Dans le prochain article, nous ferons les fonctions GET (pour une catégorie), POST, PUT et DELETE.

Le code source se trouve ici: https://github.com/jpsymfony/REST-BEHAT

Rédigé par

3 comments

  1. Bonjour,

    J’ai créé le Listerner JWTResponseListener, mais ça ne change rien, je recupere juste le token, il faudrait paramétrer quelqu’un part en précisant qu’on utilise ce listener ?

    Merci

    1. Bonjour,

      Oui, tout à fait, j’aurais dû le préciser, il faut pluger ce listener comme dans la sandbox pour qu’il prenne le relais.

      Le code se trouve sur le repo: https://github.com/jpsymfony/REST-BEHAT/blob/master/src/App/ApiAuthenticationBundle/Resources/config/services.yml

      Comme tu vois, j’ai crée un ApiAuthenticationBundle que j’ai déclaré dans le AppKernel.php, et il a dedans ses services (et un routing.yml) où les listeners sont surchargés.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.