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 »:
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
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
3 comments
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
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.
Oui j’ai regardé entre temps. Merci beaucoup