Bien que les outils de l’écosystème de OpenAPI (Swagger RESTFull API) soient assez bien fournis, rédiger manuellement toute la documentation peut se montrer assez rapidement rébarbatif.
En plus, à cause de la séparation entre le code et la documentation, cette dernière risque de ne pas être mise à jour si le code évolue.
Nous allons donc voir comment automatiser la génération de la documentation dans Symfony avec le bundle NelmioApiDocBundle.
Cette partie n’abordera pas toutes les fonctionnalités de ce bundle mais permettra d’avoir assez de bagages pour être autonome.
- Installation de NelmioApiDocBundle
- L'annotation ApiDoc
- Étendre NelmioApiDocBundle
- Le bac à sable
- Générer une documentation compatible OpenAPI
Installation de NelmioApiDocBundle
La référence en matière de documentation d’une API avec Symfony est le bundle NelmioApiDocBundle. Comme pour tous les bundles de Symfony, l’installation est particulièrement simple. Avec Composer, nous allons rajouter la dépendance :
1 2 3 | composer require nelmio/api-doc-bundle
# Using version ^2.12 for nelmio/api-doc-bundle
./composer.json has been updated
|
Nous pouvons maintenant activer le bundle :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <?php # app/AppKernel.php use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Config\Loader\LoaderInterface; class AppKernel extends Kernel { public function registerBundles() { $bundles = [ // ... new Nelmio\ApiDocBundle\NelmioApiDocBundle(), new AppBundle\AppBundle(), ]; // ... return $bundles; } // ... } |
L'annotation ApiDoc
Configuration
Pour générer de la documentation, le bunble NelmioApiDocBundle se base sur une fonctionnalité principale : l’annotation ApiDoc
.
À son installation, ce bundle met à notre disposition cette annotation qui va nous permettre de rédiger notre documentation.
Il faut garder en tête que la documentation avec NelmioApiDocBundle est grandement liée au code.
Sans plus attendre, nous allons l’utiliser pour documenter l’appel qui liste les lieux de notre application.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | <?php # src/AppBundle/Controller/PlaceController.php namespace AppBundle\Controller; // ... use Nelmio\ApiDocBundle\Annotation\ApiDoc; // ... class PlaceController extends Controller { /** * @ApiDoc( * description="Récupère la liste des lieux de l'application" * ) * * * @Rest\View(serializerGroups={"place"}) * @Rest\Get("/places") * @QueryParam(name="offset", requirements="\d+", default="", description="Index de début de la pagination") * @QueryParam(name="limit", requirements="\d+", default="", description="Nombre d'éléments à afficher") * @QueryParam(name="sort", requirements="(asc|desc)", nullable=true, description="Ordre de tri (basé sur le nom)") */ public function getPlacesAction(Request $request, ParamFetcher $paramFetcher) { // ... return $places; } // ... } |
Avec juste cette annotation, il est possible de consulter la documentation de notre API. Mais avant d’y accéder, nous devons avoir une URL dédiée. Et pour ce faire, le bundle propose un fichier de routage qui permet de configurer cette URL.
1 2 3 4 5 6 7 8 | # app/config/routing.yml # ... nelmio-api-doc: resource: "@NelmioApiDocBundle/Resources/config/routing.yml" prefix: /documentation ` |
Nous allons aussi rajouter une règle dans le pare-feu de Symfony afin d’autoriser l’accès à la documentation sans authentification.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # app/config/secrity.yml security: # ... firewalls: # disables authentication for assets and the profiler, adapt it according to your needs dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false doc: pattern: ^/documentation security: false # ... |
Notre documentation est maintenant accessible depuis l’URL http://rest-api.local/documentation.
En y accédant depuis un navigateur, nous obtenons une page générée automatiquement :
Pour avoir une vue complète (comme sur l’image), il faut cliquer sur la méthode GET /places
pour dérouler les détails concernant les filtres. La mise en page de la documentation est grandement inspiré de Swagger UI.
Intégration avec FOSRestBundle
Le premier point qui devrait vous interpeller est la présence des filtres de FOSRestBundle dans la documentation. NelmioApiDocBundle a été conçu pour interagir avec la plupart des bundles utilisés dans le cadre d’une API. Ainsi, les annotations de FOSRestBundle sont utilisées pour compléter la documentation.
Bien sûr, si nous n’utilisons pas FOSRestBundle, nous pouvons rajouter manuellement des filtres en utilisant l’attribut filters
de l’annotation ApiDoc
.
De la même façon, le verbe HTTP utilisé est GET
avec une URL /places
. Là aussi, les routes générées par Symfony sont utilisées par NelmioApiDocBundle.
Définir le type des réponses de l’API
Notre documentation n’est pas encore complète. Le type des réponses renvoyées par notre API n’est pas encore documenté.
Pour ce faire, il existe un attribut nommé output
qui prend comme paramètre le nom d’une classe ou encore une collection. Cet attribut supporte aussi les groupes de sérialisation que nous avons déjà définis.
Pour le cas des lieux, nous devons renvoyer une collection de lieux. La documentation s’écrit donc :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <?php # src/AppBundle/Controller/PlaceController.php namespace AppBundle\Controller; // ... use Nelmio\ApiDocBundle\Annotation\ApiDoc; // ... class PlaceController extends Controller { /** * @ApiDoc( * description="Récupère la liste des lieux de l'application", * output= { "class"=Place::class, "collection"=true, "groups"={"place"} } * ) * @Rest\View(serializerGroups={"place"}) * @Rest\Get("/places") * @QueryParam(name="offset", requirements="\d+", default="", description="Index de début de la pagination") * @QueryParam(name="limit", requirements="\d+", default="", description="Nombre d'éléments à afficher") * @QueryParam(name="sort", requirements="(asc|desc)", nullable=true, description="Ordre de tri (basé sur le nom)") */ public function getPlacesAction(Request $request, ParamFetcher $paramFetcher) { // ... } // ... } |
La documentation devient :
La documentation est complétée et les attributs ont exactement les bon types définis dans les annotations Doctrine. Pour obtenir de telles informations, NelmioApiDocBundle utilise le sérialiseur de JMSSerializerBundle.
Par contre, si nous étions restés sur le sérialiseur natif de Symfony qui n’est pas encore supporté, nous n’aurions pas pu obtenir ces informations.
Les descriptions de tous les attributs sont vides. Pour les renseigner, il suffit de rajouter dans les entités une description dans le bloc de PHPDoc.
Pour l’entité Place
, nous pouvons rajouter :
1 2 3 4 5 6 7 8 9 | <?php /** * Identifiant unique du lieu * * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue */ protected $id; |
La documentation générée devient alors :
Définir le type des payloads des requêtes
De la même façon, pour définir la structure des payloads des requêtes, nous pouvons utiliser un attribut nommé input
qui peut prendre en paramètre, entre autres, une classe qui implémente l’interface PHP JsonSerializable
mais aussi un formulaire Symfony. Et cela tombe bien puisse que tous nos payloads se basent sur ces formulaires.
Pour tester le bon fonctionnement de cet attribut, nous allons rajouter de la documentation pour la méthode de création d’un lieu.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | <?php # src/AppBundle/Controller/PlaceController.php namespace AppBundle\Controller; // ... use Nelmio\ApiDocBundle\Annotation\ApiDoc; // ... class PlaceController extends Controller { // ... /** * @ApiDoc( * description="Crée un lieu dans l'application", * input={"class"=PlaceType::class, "name"=""} * ) * * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"place"}) * @Rest\Post("/places") */ public function postPlacesAction(Request $request) { // ... } // ... } |
Pour rajouter des descriptions pour les différents attributs des formulaires, nous pouvons utiliser une option nommée description
rajoutée aux formulaires Symfony par NelmioApiDocBundle.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | <?php # src/AppBundle/Form/Type/PlaceType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class PlaceType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('name', TextType::class, [ 'description' => "Nom du lieu" ]); $builder->add('address', TextType::class, [ 'description' => "Adresse complète du lieu" ]); $builder->add('prices', CollectionType::class, [ 'entry_type' => PriceType::class, 'allow_add' => true, 'error_bubbling' => false, 'description' => "Liste des prix pratiqués" ]); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => 'AppBundle\Entity\Place', 'csrf_protection' => false ]); } } |
Gérer plusieurs codes de statut
En définissant l’attribut output
, le code de statut associé par défaut est 200. Mais pour la création d’un lieu, nous devons avoir un code 201. Et de la même façon si le formulaire est invalide, nous voulons renvoyer une erreur 400 avec les messages de validation. Pour obtenir un tel résultat, NelmioApiDocBundle met à notre disposition un attribut responseMap
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | <?php # src/AppBundle/Controller/PlaceController.php namespace AppBundle\Controller; // ... use Nelmio\ApiDocBundle\Annotation\ApiDoc; // ... class PlaceController extends Controller { // ... /** * @ApiDoc( * description="Crée un lieu dans l'application", * input={"class"=PlaceType::class, "name"=""}, * statusCodes = { * 201 = "Création avec succès", * 400 = "Formulaire invalide" * }, * responseMap={ * 201 = {"class"=Place::class, "groups"={"place"}}, * 400 = { "class"=PlaceType::class, "form_errors"=true, "name" = ""} * } * ) * * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"place"}) * @Rest\Post("/places") */ public function postPlacesAction(Request $request) { // ... } // ... } |
Le paramètre form_errors
permet de spécifier le type de retour que nous voulons à savoir les erreurs de validation.
Ici, nous avons bien deux réponses selon le code de statut mais pour la réponse lors d’un requête invalide, le format n’est pas correct (pas d’attribut children
, l’attribut status_code
s’appelle code
, etc.).
Étendre NelmioApiDocBundle
Pourquoi étendre le bundle ?
Pour corriger les petits manquements de NelmioApiDocBundle, nous allons étendre le code de celui-ci. L’objectif n’est pas d’apprendre le code source de ce bundle mais plutôt de maximiser son efficacité en l’adaptant à nos besoins.
Il est possible d’obtenir de la documentation en redéfinissant manuellement toutes ces informations manquantes. Mais l’intérêt réel de ce bundle réside dans le fait d’utiliser les composants déjà existants pour générer la documentation automatiquement. N’hésitez donc pas à consulter la documentation officielle de NelmioApiDocBundle pour plus d’informations.
Correction du format de sortie des réponses en erreur
Il n’y a pas de documentation sur comment étendre NelmioApiDocBundle. Mais vu que ce bundle est open source, il suffit de relire avec attention son code pour comprendre son fonctionnement.
Il en ressort que pour traiter les informations disponibles dans les attributs input
et output
de l’annotation ApiDoc
, le bundle utilise des parseurs.
Et la documentation officielle nous explique comment en créer et comment l’utiliser.
Nous allons donc créer un parseur capable de générer les erreurs de validation au même format que FOSRestBundle.
Ce code est grandement inspiré du parseur déjà existant (FormErrorsParser).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | <?php # src/Component/ApiDoc/Parser/FOSRestFormErrorsParser.php namespace Component\ApiDoc\Parser; use Nelmio\ApiDocBundle\DataTypes; use Nelmio\ApiDocBundle\Parser\ParserInterface; use Nelmio\ApiDocBundle\Parser\PostParserInterface; class FOSRestFormErrorsParser implements ParserInterface, PostParserInterface { public function supports(array $item) { return isset($item['fos_rest_form_errors']) && $item['fos_rest_form_errors'] === true; } public function parse(array $item) { return array(); } public function postParse(array $item, array $parameters) { $params = []; // Il faut d'abord désactiver tous les anciens paramètres créer par d'autres parseurs avant de reformater foreach ($parameters as $key => $parameter) { $params[$key] = null; } $params['code'] = [ 'dataType' => 'integer', 'actualType' => DataTypes::INTEGER, 'subType' => null, 'required' => false, 'description' => 'The status code', 'readonly' => true ]; $params['message'] = [ 'dataType' => 'string', 'actualType' => DataTypes::STRING, 'subType' => null, 'required' => true, 'description' => 'The error message', 'default' => 'Validation failed.', ]; $params['errors'] = [ 'dataType' => 'errors', 'actualType' => DataTypes::MODEL, 'subType' => sprintf('%s.FormErrors', $item['class']), 'required' => true, 'description' => 'List of errors', 'readonly' => true, 'children' => [ 'children' => [ 'dataType' => 'List of form fields', 'actualType' => DataTypes::MODEL, 'subType' => sprintf('%s.Children', $item['class']), 'required' => true, 'description' => 'Errors', 'readonly' => true, 'children' => [] ] ] ]; foreach ($parameters as $name => $parameter) { $params['errors']['children']['children']['children'][$name] = $this->doPostParse($parameter, $name, [$name], $item['class']); } return $params; } protected function doPostParse($parameter, $name, array $propertyPath, $type) { $data = [ 'dataType' => 'Form field', 'actualType' => DataTypes::MODEL, 'subType' => sprintf('%s.FieldErrors[%s]', $type, implode('.', $propertyPath)), 'required' => true, 'description' => 'Field name', 'readonly' => true, 'children' => [ 'errors'=> [ 'dataType' => 'errors', 'actualType' => DataTypes::COLLECTION, 'subType' => 'string', 'required' => false, 'description' => 'List of field error messages', 'readonly' => true ] ] ]; if ($parameter['actualType'] == DataTypes::COLLECTION) { $data['children']['children'] = [ 'dataType' => 'List of embedded forms fields', 'actualType' => DataTypes::COLLECTION, 'subType' => sprintf('%s.FormErrors', $parameter['subType']), 'required' => true, 'description' => 'Validation error messages', 'readonly' => true, 'children' => [ 'children' => [ 'dataType' => 'Embedded form field', 'actualType' => DataTypes::MODEL, 'subType' => sprintf('%s.Children', $parameter['subType']), 'required' => true, 'description' => 'List of errors', 'readonly' => true, 'children' => [] ] ] ]; foreach ($parameter['children'] as $cName => $cParameter) { $cPropertyPath = array_merge($propertyPath, [$cName]); $data['children']['children']['children']['children']['children'][$cName] = $this->doPostParse($cParameter, $cName, $cPropertyPath, $parameter['subType']); } } return $data; } } |
Ce parseur doit toujours être utilisé avec FormTypeParser
qui apporte l’ensemble des informations issues du formulaire Symfony. Pour l’activer, il faut utiliser l’attribut : fos_rest_form_errors
(voir la méthode supports
).
Pour le déclarer en tant parseur prêt à l’emploi, nous devons créer un service avec le tag nelmio_api_doc.extractor.parser
.
1 2 3 4 5 6 7 8 9 10 | # app/config/services.yml services: # ... app_bundle.api_doc.fos_rest_form_errors_parser: class: Component\ApiDoc\Parser\FOSRestFormErrorsParser tags: - { name: nelmio_api_doc.extractor.parser, priority: 1 } ` |
Tous les parseurs natifs du bundle sont déclarés avec une priorité de 0. En utilisant une priorité de 1, nous nous assurons que notre parseur est toujours appelé en dernier.
Pour utiliser notre parseur, nous allons ajuster l’annotation sur le contrôleur des lieux en utilisant l’attribut fos_rest_form_errors
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | <?php # src/AppBundle/Controller/PlaceController.php namespace AppBundle\Controller; // ... use Nelmio\ApiDocBundle\Annotation\ApiDoc; // ... class PlaceController extends Controller { // ... /** * @ApiDoc( * description="Crée un lieu dans l'application", * input={"class"=PlaceType::class, "name"=""}, * statusCodes = { * 201 = "Création avec succès", * 400 = "Formulaire invalide" * }, * responseMap={ * 201 = {"class"=Place::class, "groups"={"place"}}, * 400 = { "class"=PlaceType::class, "fos_rest_form_errors"=true, "name" = ""} * } * ) * * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"place"}) * @Rest\Post("/places") */ public function postPlacesAction(Request $request) { // ... } // ... } |
La réponse pour un formulaire invalide est maintenant correctement formatée.
Le bac à sable
Comme pour OpenAPI (Swagger RESTFul API), NelmioApiDocBundle propose un bac à sable permettant de tester la documentation. Avant d’utiliser ce bac à sable, nous allons rajouter quelques informations de configuration.
Configuration du bac à sable
La documentation officielle sur le bac à sable est concise et simple. Les paramètres disponibles sont d’ailleurs assez proches de ceux d’OpenAPI.
Voyez donc par vous-même.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | # app/config/config.yml nelmio_api_doc: sandbox: enabled: true # Juste pour la lisibilité car true est déjà la valeur par défaut endpoint: http://rest-api.local authentication: name: X-Auth-Token delivery: header accept_type: application/json # valeur par défaut de l'entête Accept body_format: formats: [ json, xml ] default_format: json request_format: formats: json: application/json xml: application/xml method: accept_header default_format: json |
Cette configuration est assez explicite et se passe donc de commentaires. En accédant à la documentation avec l’URL http://rest-api.local/documentation, nous obtenons :
Le bac à sable est disponible en cliquant sur l’onglet Sandbox.
Documentation pour la création de token
Avant de tester ce bac à sable, nous allons rajouter de la documentation pour la création de token d’authentification. Cela facilitera grandement nos tests.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | <?php # src/AppBundle/Controller/AuthTokenController.php namespace AppBundle\Controller; // ... use Nelmio\ApiDocBundle\Annotation\ApiDoc; // ... class AuthTokenController extends Controller { /** * @ApiDoc( * description="Crée un token d'authentification", * input={ "class" = CredentialsType::class, "name"=""}, * statusCodes = { * 201 = "Création avec succès", * 400 = "Formulaire invalide" * }, * responseMap={ * 201 = {"class"=AuthToken::class, "groups"={"auth-token"}}, * 400 = { "class"=CredentialsType::class, "fos_rest_form_errors"=true, "name" = ""} * } * ) * * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"auth-token"}) * @Rest\Post("/auth-tokens") */ public function postAuthTokensAction(Request $request) { //... } //... } |
Nous pouvons maintenant créer un token depuis le bac à sable.
Tester le bac à sable
Vu que toutes nos méthodes nécessites une authentification, il faut d’abord crée un token d’authentification. Ce token doit être renseigné dans le formulaire api_key
.
Avec la configuration que nous avons mise en place, ce token sera envoyé automatiquement pour toutes nos requêtes.
Maintenant pour récupérer les lieux de l’application, il suffit de cliquer sur le bouton Try it!.
Générer une documentation compatible OpenAPI
Pour profiter des différents outils disponibles dans l’écosystème de Swagger, NelmioApiDocBundle propose d’exporter la configuration au format OpenAPI.
Pour ce faire, il faut rajouter un attribut resource
à nos annotations ApiDoc
. Ensuite, il suffit d’utiliser la commande php bin/console api:swagger:dump dossier_de_destination
.
Voici un exemple de configuration qui remplit ce contrat :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | <?php # src/AppBundle/Controller/AuthTokenController.php namespace AppBundle\Controller; // ... use Nelmio\ApiDocBundle\Annotation\ApiDoc; // ... class AuthTokenController extends Controller { /** * @ApiDoc( * resource=true, * description="Crée un token d'authentification", * input={ "class" = CredentialsType::class, "name"=""}, * statusCodes = { * 201 = "Création avec succès", * 400 = "Formulaire invalide" * }, * responseMap={ * 201 = {"class"=AuthToken::class, "groups"={"auth-token"}}, * 400 = { "class"=CredentialsType::class, "fos_rest_form_errors"=true, "name" = ""} * } * ) * * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"auth-token"}) * @Rest\Post("/auth-tokens") */ public function postAuthTokensAction(Request $request) { // ... } // ... } ` |
Les métadonnées concernant la documentation peuvent être modifiées en configurant le bundle.
1 2 3 4 5 6 7 8 9 10 11 12 13 | # app/config/config.yml # ... nelmio_api_doc: # ... swagger: api_base_path: / swagger_version: '1.2' api_version: '1.0' info: title: Rest API description: 'Proposition de sortie aux utilisateurs' ` |
Avec la version 2.13.0, ce bundle génère un fichier swagger.json en utilisant la version 1.2 des spécifications d’OpenAPI alors qu’il existe une version 2.0. Le fichier généré ne sera donc pas à jour même si dans la configuration nous mettons 2.0 comme valeur de l’attribut swagger_version
.
En exécutant la commande :
1 2 3 | php bin/console api:swagger:dump --pretty web/swagger Dumping resource list to web/swagger/api-docs.json: OK Dump API declaration to web/swagger/auth-tokens.json: OK |
Les fichiers ainsi générés dans le dossier web/swagger peuvent être exploités par tous les outils compatibles avec OpenAPI.
Pour les tester, il suffit d’éditer le fichier web/swagger-ui/dist/indext.html et de remplacer la ligne var url ="/swagger.json";
par var url ="/swagger/auth-tokens.json";
.
En accédant à l’URL http://rest-api.local/swagger-ui/dist/index.html, la documentation générée s’affiche.
ApiDocBundle supporte les différents bundles de Symfony et le tout permet d’avoir un ensemble harmonieux et facilite les développements.
L’un des problèmes les plus communs lorsque nous écrivons une documentation est de la maintenir à jour. Avec une documentation proche du code, il est maintenant très facile de la corriger en même temps que le code évolue.
En effet, un utilisant les annotations de FOSRestBundle, les formulaires de Symfony et les fichiers de sérialisation de JMSSerializerBundle, nous avons la garantie que la documentation est toujours à jour avec notre code.
Il ne reste plus qu’à tout mettre en production !