Make documentation great again (2/2)

8 novembre 2021

Cet article fait partie de la série "Make documentation great again"

Nous avons vu précédemment une présentation du langage AsciiDoc et des avantages de la "Documentation as Code".

Il est néanmoins possible de faciliter encore plus la rédaction de la documentation ! En effet, il est de coutume de documenter l’API que l’on développe, pour faciliter sa maintenance et les interactions avec ses "consommateurs".

Même si l’une des 4 valeurs de l’agilité promeut "un logiciel fonctionnel plutôt qu’une documentation exhaustive", nous allons voir que l’on peut proposer les deux en même temps grâce à un outil particulièrement pratique.

Spring REST Docs

Spring REST Docs permet d’alléger grandement la rédaction d’une documentation d’API, en combinant une rédaction manuelle et l’injection de sections autogénérées.

Il s’appuie sur Spring MVC Test, largement utilisé pour les tests de la couche "web" d’une application Spring. Autrement dit, son ajout sur un projet déjà entamé ne demandera pas une refonte complète de ses tests …​ et c’est évidemment une bonne chose !

Un projet de démonstration

De manière à nous baser sur un exemple concret, je vous propose un petit projet de démonstration disponible sur GitHub.

Dans ces cas-là, inutile d’imaginer un cas d’usage très compliqué, j’ai donc mis en place un simple CRUD manipulant un objet Company. Une Company est définie par un ID, un nom, un lieu et une date de création.

Pour ressembler à un vrai projet, j’ai tout de même ajouté une interaction avec une base MongoDB qu’il faut démarrer avant de lancer l’application. Pour ajouter une Company, on peut donc faire une requête POST avec comme body :

{
    "name": "CoolCorp",
    "location": "Paris",
    "creationDate": "2021-10-29"
}

Ajouter la dépendance nécessaire

Avant tout, il convient d’ajouter la dépendance "Spring REST Docs" au projet :

Extrait du fichier pom.xml :
        <dependency>
            <groupId>org.springframework.restdocs</groupId>
            <artifactId>spring-restdocs-mockmvc</artifactId>
            <scope>test</scope>
        </dependency>

Quant au reste des dépendances constituant cette application, inutile de les lister ici, elles sont très classiques et indépendantes de l’outil générant la documentation.

Modifier ses tests de Controller pour générer de la documentation

Il est alors possible de regarder les tests et d’y ajouter des instructions dédiées à générer les éléments qui nous intéressent. Concentrons sur le test de la requête permettant de récupérer une Company à partir de son ID.

À noter que je me suis contenté de mocker la couche de service pour ces tests de controller, il ne s’agit donc clairement pas de tests end-to-end.

    @Test
    void getCompany() throws Exception {
        final String ID = "ID_1";

        when(companyService.getCompany(ID)).thenReturn(company1);

        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/companies/{id}", ID)) // (1)
                .andExpect(handler().handlerType(CompanyController.class)) // (2)
                .andExpect(handler().methodName("getCompany"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().json(objectMapper.writeValueAsString(company1)))
                .andDo(document( // (3)
                        "getCompany",
                        ControllerTestUtils.preprocessRequest(),
                        ControllerTestUtils.preprocessResponse(),
                        pathParameters(parameterWithName("id").description("The requested company id")), // (4)
                        responseFields( // (5)
                                fieldWithPath("id").description("The company unique ID"),
                                fieldWithPath("name").description("The company name"),
                                fieldWithPath("location").description("The company location"),
                                fieldWithPath("creationDate").description("The company creation date"))));
    }
  1. Première nouveauté : on utilise un RestDocumentationRequestBuilders pour émettre la requête plutôt qu’un traditionnel MockMvcRequestBuilders. La différence ? Il est chargé de capturer les informations de la requête pour la documenter.

  2. On déroule les tests habituels : vérifier que la requête est traitée par la méthode getCompany() du CompanyController, que la réponse porte le code HTTP 200 et contient l’objet prévu.

  3. Nouvelle instruction ! Ici on lance la génération de documentation, sous le nom de "getCompany" pour cette requête.
    Oublions les 2 lignes suivantes pour le moment.

  4. Documentons la partie variable de l’URL : l’ID de la Company est passé en "path parameter", nous pouvons le décrire (même si dans notre cas ce paramètre est assez facilement compréhensible).

  5. Documentons la réponse de la requête : nous pouvons décrire chaque champ constituant la Company.

Désormais, si vous ajoutez un champ dans l’objet Company sans le documenter dans ce test, il ne passera plus. Idem si vous supprimez un champ, le test ne passera plus, car il cherchera à documenter un élément qui n’existe pas. Il s’agit donc d’une nouvelle sécurité : votre API ne pourra plus évoluer sans que vous en ayez totalement conscience !

Nous avons donc un test qui documente le cas passant le plus évident : lorsqu’une Company correspond à l’ID demandé est trouvée. Lançons-nous dans un nouveau test pour le cas contraire.

Extrait de CompanyControllerTest avec le test d’une erreur
    @Test
    void getCompanyNotFound() throws Exception {
        final String ID = "ID_3";

        when(companyService.getCompany(ID)).thenThrow(new CompanyNotFoundException());

        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/companies/{id}", ID))
                .andExpect(handler().handlerType(CompanyController.class))
                .andExpect(handler().methodName("getCompany"))
                .andDo(print())
                .andExpect(status().isNotFound())
                .andDo(document(
                        "getCompanyNotFound", // (1)
                        ControllerTestUtils.preprocessRequest(),
                        ControllerTestUtils.preprocessResponse()));
    }
  1. Comme dans le test précédent, on ajoute l’instruction document(…​), cette fois-ci avec un nouvel identifiant ("getCompanyNotFound") pour différencier la documentation générée dans ce nouveau cas.

En revanche, il n’est pas ici utile de générer plus de documentation, dans la mesure où la description du "path parameter" a déjà été faite dans le test précédent, et où la requête ne renvoie rien d’autre qu’une erreur HTTP 404.

En lançant les 2 tests que nous venons de voir, des fichiers (on parle de "snippets") vont être générés sous target/generated-snippets. Et comme par hasard, il s’agit de fichiers .adoc !

Aperçu des fichiers générés
Aperçu des fichiers générés

Si l’on ouvre par exemple getCompany/response-fields.adoc, on pourra y trouver :

|===
|Path|Type|Description

|`+id+`
|`+String+`
|The company unique ID

|`+name+`
|`+String+`
|The company name

|`+location+`
|`+String+`
|The company location

|`+creationDate+`
|`+String+`
|The company creation date

|===

Disons-le : même si l’on voit bien que l’on retrouve des éléments saisis dans le test, ce n’est guère lisible…​ Et puis il n’y a pas moins de 14 fichiers générés pour deux petits tests, qui lirait ça ?!

One does not simply read 14 files for 1 single endpoint

Il va donc être temps de générer une documentation plus lisible !

Le fichier source de la documentation

Un fichier pour les gouverner tous. C’est en tout cas l’objectif que nous devons nous fixer pour rendre viable notre documentation.

Nous allons donc ajouter un fichier sous source/asciidoctor : index.adoc. Et c’est là que nous injecterons nos "snippets" (et uniquement ceux qui nous intéressent), avec un peu de texte ajouté manuellement pour rendre l’ensemble plus digeste.

Extrait du fichier index.adoc qui deviendra la documentation
=== Get one company // (1)

.Request
\include::{snippets}/getCompany/http-request.adoc[] // (2)

.Path parameters
\include::{snippets}/getCompany/path-parameters.adoc[] // (3)

.Response
\include::{snippets}/getCompany/http-response.adoc[] // (4)

.Response fields
\include::{snippets}/getCompany/response-fields.adoc[] // (5)

.Response if the company was not found
\include::{snippets}/getCompanyNotFound/http-response.adoc[] // (6)
  1. Donnons un petit titre à cette partie de la documentation pour décrire le endpoint documenté en dessous.

  2. On injecte un snippet contenant la requête, c’est probablement la meilleure représentation de celle-ci.

  3. On injecte un snippet contenant la description du "path parameter".

  4. Et le snippet qui illustre la réponse reçue.

  5. N’oublions pas le snippet qui décrit les champs de la réponse.

  6. Nous avions deux tests sur cet endpoint, pensons à montrer à quoi ressemble la réponse dans le cas où la Company n’est pas trouvée.

Pour le moment nous avons juste créé un squelette de documentation. Selon votre IDE, peut-être que vous apercevez déjà un rendu !

De manière à améliorer la documentation, vous pouvez évidemment ajouter une table des matières, un titre principal, etc. Il s’agit là de fonctionnalités et annotations de base d’AsciiDoc, je vous propose de ne pas nous y attarder. Vous pouvez avoir une idée de ce que l’on peut faire en regardant le fichier index.adoc du projet de démonstration.

Générer la documentation

Maintenant que nous savons générer des "snippets" et que nous pouvons les rassembler en un seul fichier, il est temps d’automatiser la génération de la page HTML qui contiendra toute la documentation.

Pour cela, nous allons nous appuyer sur un plugin Maven : asciidoctor-maven-plugin.

La configuration à ajouter dans le fichier pom.xml
    <build>
        <plugins>
            <plugin>
                <groupId>org.asciidoctor</groupId>
                <artifactId>asciidoctor-maven-plugin</artifactId>
                <version>2.2.1</version>
                <executions>
                    <execution>
                        <id>generate-docs</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>process-asciidoc</goal>
                        </goals>
                        <configuration>
                            <backend>html</backend>
                            <doctype>book</doctype>
                            <attributes>
                                <snippets>${project.build.directory}/generated-snippets</snippets>
                            </attributes>
                            <logHandler>
                                <outputToConsole>true</outputToConsole>
                                <failIf>
                                    <severity>DEBUG</severity>
                                </failIf>
                            </logHandler>
                        </configuration>
                    </execution>
                </executions>
                <dependencies>
                    <dependency>
                        <groupId>org.springframework.restdocs</groupId>
                        <artifactId>spring-restdocs-asciidoctor</artifactId>
                        <version>${spring-restdocs.version}</version>
                    </dependency>
                </dependencies>
            </plugin>

            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>${jacoco-maven-plugin.version}</version>
                <executions>
                    <execution>
                        <id>prepare-agent</id>
                        <goals>
                            <goal>prepare-agent</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>generate-report</id>
                        <phase>verify</phase>
                        <goals>
                            <goal>report</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

Ce code permet de générer une page HTML en piochant dans le répertoire target/generated-snippets, tout en bloquant le build en cas d’erreur rencontrée (snippet manquant, etc.).

En exécutant une commande comme mvn package, les tests seront exécutés, donc les snippets générés et finalement la page index.html sera créée à partir du fichier index.adoc. Et on peut la retrouver dans target/generated-docs.

Aperçu de la page HTML générée
Aperçu de la page HTML générée

Nous avons donc vu comment générer une page HTML décrivant les endpoints d’une application Spring Boot grâce à Spring REST Docs, sans refactorer tous les tests de controller.

L’outil est assez complet et il resterait encore plein de petits détails à montrer pour être plus exhaustif. Mais je vous propose d’attendre un article présentant des bonus !


Liens utiles :