liksi logo

Personnaliser OpenAPI avec SpringDoc

Yoann Breton - Publié le 06/03/2023
Personnaliser OpenAPI avec SpringDoc

Contexte

Nous développons une application pour notre bien aimé coffee shop local “Octopuccino”, nous avons un backend avec Kotlin Springboot exposant des endpoints. Chaque endpoint a une restriction de sécurité différente.

  • le rôle CUSTOMER : peut consulter le menu des cafés que nous proposons (GET /menu). Il peut ensuite passer une commande (POST /orders) pour qu’elle soit ajoutée à la liste des commandes.
  • le rôle BARISTA : prépare la commande (/PUT /orders/{id}/prepare) et la sert (/PUT /orders/{id}/serve).
  • le rôle OWNER : le gérant du café, qui a un accès total à la BDD des employés.

Dans notre équipe, la QA fait des tests end-to-end comprenant des appels à notre API. Notre API est déjà documentée grâce à une documentation OpenAPI qu’elle consulte avec une UI Swagger, le tout généré avec SpringDoc OpenApi.

Le problème est que notre QA ne connaît pas les restrictions d’autorisation des différents rôles et ce n’est pas renseigné par la documentation OpenApi. Pourtant nous définissons bien c’est information par endpoint à l’aide de l’annotation @Secured de Spring Security. Une solution naïve est de rajouter cette information manuellement par endpoint :

@Secured(Roles.BARISTA, Roles.OWNER)
@GetMapping
@Operation(summary = "Get all customers orders", description = "Restricted to roles barista and owner")

Cependant cette solution à plusieurs désavantages :

  • C’est chronophage et répétitif, deux adjectifs qui irritent souvent le développeur
  • Chaque modification et ajout de endpoint demande de penser à mettre à jour cette partie de la documentation

La solution idéale, et c’est ce que nous allons faire ici, est de générer automatiquement cette partie de la documentation OpenAPI à partir de nos annotation @Secured, de sorte à n’avoir qu’une seule source de vérité : notre code.

En parlant de source de vérité, voici à quoi ressemble donc nos controllers (simplifié) :

@RestController
@RequestMapping("/orders")
@Tag(name = "Order controller")
class OrderCtrl {

    @Secured(Roles.BARISTA, Roles.OWNER)
    @GetMapping
    @Operation(summary = "Get all customers orders")
    fun getAllOrders() {}

    @Secured(Roles.CUSTOMER)
    @PostMapping
    @Operation(summary = "Place an order")
    fun placeOrder() {}

    @Secured(Roles.BARISTA)
    @PutMapping("/{id}/prepare")
    @Operation(summary = "Prepare order")
    fun prepareOrder(@PathVariable id: String) {}

    @Secured(Roles.BARISTA)
    @PutMapping("{id}/serve")
    @Operation(summary = "Serve order")
    fun serveOrder(@PathVariable id: String) {}
}

Pour notre OrderCtrl nous définissons nos @Secured par Opération dans le Path.

@RestController
@RequestMapping("/employees")
@Secured(Roles.OWNER)
@Tag(name = "Employee controller")
class EmployeeCtrl {
    @GetMapping
    @Operation(summary = "Get all employees")
    fun getAllEmployees() {}

    @GetMapping("/{id}")
    @Operation(summary = "Get one employee")
    fun getOneEmployee(@PathVariable id: String) {}

    @PostMapping
    @Operation(summary = "Create an employee")
    fun createEmployee() {}

    @PutMapping
    @Operation(summary = "Update an employee")
    fun updateEmployee() {}

    @DeleteMapping
    @Operation(summary = "Delete an employee")
    fun deleteEmployee() {}
}

Pour le EmployeeCtrl, c’est toujours la même valeur, donc nous définissons le @Secured au niveau de la classe.

Personnaliser OpenApi avec SpringDoc

Heureusement pour nous, SpringDoc nous permet de facilement de personnaliser la génération des objets OpenAPI grâce à plusieurs customizer mis à notre disposition. On retrouve cette liste ici.

Ce que l’on cherche à personnaliser sont les opérations. Il nous faut donc utiliser l’interface OperationCustomizer.

Pour cela 2 façons s’offrent à nous, soit on implémente l’interface OperationCustomizer dans une classe qu’on annote avec @Component, soit on créer une classe annoté avec @Configuration dans laquelle on déclare un @Bean qui retourne un OperationCustomizer. Personnellement, je préfère la deuxième option, la syntaxe paraît plus concise et permet de regrouper plusieurs customizer dans notre classe de configuration :

@Configuration
class SpringDocConfig {

    @Bean
    fun customizeOperation(): OperationCustomizer {
        return OperationCustomizer { operation, handlerMethod ->
            val classSecuredAnnotation = handlerMethod.beanType.annotations.find { it is Secured } as Secured?
            val methodSecuredAnnotation = handlerMethod.method.annotations.find { it is Secured } as Secured?

            val roles = (methodSecuredAnnotation ?: classSecuredAnnotation)?.value
            roles?.let {
                val joinedRoles = it.joinToString(separator = "\n- ", prefix = "\n- ") { role -> 
                    role.removePrefix("ROLE_").lowercase() 
                }
                val description = operation.description ?: ""
                operation.description = """
                    |$description
                    |
                    |ROLES : 
                    |$joinedRoles
                """.trimMargin()
            }

            operation
        }
    }
    
}

Ce qui est important ici est que la fonction customize de OperationCustomizer nous passe en paramètre un HandlerMethod représentant notre fonction associé à l’annotation @Operation. C’est la base qui nous permet d’aller récupérer les annotations @Secured de la fonction et de notre classe.

val classSecuredAnnotation = handlerMethod.beanType.annotations.find { it is Secured } as Secured?
val methodSecuredAnnotation = handlerMethod.method.annotations.find { it is Secured } as Secured?

On peut ensuite facilement récupérer les rôles utilisés dans notre annotation. Comme les annotations sur notre méthode surcharge celles sur notre classe, on regarde d’abord l’annotation @Secured de notre méthode. Sinon on regarde celle sur notre classe

val roles = (methodSecuredAnnotation ?: classSecuredAnnotation)?.value

Ensuite c’est juste du formatage, j’ai choisi d’afficher les rôles dans la description de l’Opération. On aurait aussi pu l’afficher dans la partie “summary”.

Comme on peut le voir dans la documentation de l’objet Operation, il est possible d’utiliser la syntaxe “CommonMark”, une spécification du Markdown. On l’utilise ici pour afficher sous forme de liste à puce.

Nous nous retrouvons donc avec cet affichage :

Notre QA a les informations dont elle a besoin, plus important encore, nous n’aurons jamais besoin de mettre à jour cette partie de documentation puisqu’elle est piloté par notre code.

Cas spéciaux

Parfois on peut rencontrer des cas d’autorisations spéciaux. Reprenons notre EmployeeCtrl et imaginons que nous autorisons le rôle BARISTA à faire un GET /employees/{id} si l’ID correspond à son ID.

Comme c’est un cas exceptionnel, il est acceptable de le documenter à la main :

@Secured(Roles.OWNER, Roles.BARISTA)
@GetMapping("/{id}")
@Operation(summary = "Get one employee", description = "Role Barista can only access his profile")
fun getOneEmployee(@PathVariable id: String) {}

Ressources

Derniers articles