Génération de documentation OpenAPI avec Spring

Swagger UI est un outil de visualisation d’API HTTP, basé sur Swagger v2 ou OpenAPI v3. Il exploite des données des données JSON enregistrées depuis un éditeur ou générée depuis le code de l’application.

Example Swagger UI

Habituellement, dans les projets basés sur Spring Framework, j’utilisais Springfox pour générer les informations Swagger v2 directement depuis l’application déployée. En migrant Spring Boot v3, j’ai dû passer à Springdoc et OpenAPI v3.

OpenAPI v3

Les endpoints sont enrichis avec des annotation OpenAPI v3, en vue de la génération d’une page Swagger UI.

OpenAPI logo

Certaines annotations d’OpenAPI v3 ont le même nom qu’en Swagger v2, il faut donc être attentif aux imports: io.swagger.v3.oas.annotations. Ces classes homonymes sont généralement préfixées par Api, comme @ApiResponse.

Classes Controller

Une classe Controller est un regroupement de endpoints et est décrite par un tag, avec l’annotation @Tag.

@Tag(name = "Products")
public class ProductController {
  ...
}

Endpoint method

Chaque méthode de endpoint est décrite par une annotation @Operation. L’attribut summary est obligatoire et description est optionnel.

  @Operation(summary = "Create a new product")
  public ResponseEntity<CreationDTO> create(@RequestBody Product product) {
    ...
  }

Les réponses possibles sont dévrites par des annotations @ApiResponse. Celles-ci peuvent être placées dans une annotation @ApiResponses, mais contrairement à Swagger v2 ce n’est plus obligatoire.

  • responseCode (required) est le status code au format texte.

  • description (optional) est une description de la response ; avec la configuration ci-dessous, on peut faire en sorte que la valeur par défaut est la reason phrase du statues HTTP.

  • content (optional) est généralement inutile car inféré du type de retour ; la configuration peut aussi mettre une valeur par défaut pour les erreurs.

  @Operation(summary = "Bla bla")
  @ApiResponse(responseCode = "201")
  @ApiResponse(responseCode = "401")
  @ApiResponse(responseCode = "409", description = "Conflict, similar product already exists")
  public ResponseEntity<CreationDTO> create(@RequestBody Product product) {
    ...
  }

Parameters

Par défaut, les paramètres de requêtes sont décrit en JSON. Si un paramètre représente un ensemble de paramètres éclatés, il faut annoté l’argument de la méthode par @ParameterObject.

  • Ce comportement était différent avec Springfox.

  • L’annotation @ParameterObject ne fait pas partie d’OpenAPI, elle est spécifique à Springdoc

  @Operation(summary = "Bla bla")
  public ResponseEntity<List<Product>> getAll(@ParameterObject Pageable pageable) {
    ...
  }

Cette annotation peut aussi être utilisée sur la classe du paramètre.

@ParameterObject
public class ProductFilter {
  ...
}

Ainsi elle s’appliquera à chaque utilisation.

  @Operation(summary = "Bla bla")
  public ResponseEntity<Page<Product>> getPage(
            @ParameterObject Pageable pageable, ProductFilter filter) {
    ...
  }

On peut aussi déclarer des paramètres supplémentaires avec l’annotation @Parameter sur la méthode. Ces paramètres sont généralement récupérés sur l’objet request plutôt que par un argument de la méthode.

Headers

Les entêtes de requêtes sont aussi déclarée avec l’annotation @Parameter, en mettant l’attibut in à ParameterIn.HEADER.

  @Parameter(name = TENANT_HEADER, in = ParameterIn.HEADER, required = true)
  public ResponseEntity<Product> get(@PathVariable UUID id) {
    ...
  }

Des en-têtes peuvent aussi être ajoutées en fonction de la présence d’annotations personnalisées. Dans la configuration, on peut détecter la présence d’une annotation sur une méthode et ajouter le header, comme expliqué ci-dessous.

Springdoc

Springdoc permet d’exploiter les annotations OpenAPI et de générer une structure de métadonnées exploitable par SwaggerUI. Springfox fait la même chose, mais ne fonctionne plus avec Spring Boot 3 et Spring Framework 6.

Springdoc logo

Configuration

La configuration de Springdoc se fait un peu dans le fichier application.yml et beaucoup par la production de beans.

@Configuration
public class OpenApiConfiguration {

  @Bean
  public OpenAPI openApi() {
    ...
  }

  @Bean
  public GroupedOpenApi openApiMain() {
    ...
  }

}

Le bean OpenAPI permet de spécifier quelques informations générales qui apparaitront dans la page d’accueil de SwaggerUI.

  @Bean
  public OpenAPI openAPI() {
    return new OpenAPI()
              .info(new Info()
                      .title("JTips API Documentation")
                      .description("This is a fake documentation, as JTIps does not have an API.")
                      .version("v1.0.4"));
  }

On peut avoir plusieurs beans de type GroupedOpenApi, chacun représentant une partie de l’API. Pour chaque groupe, on spécifie quelques information générales, comme le nom, les paquetages Java à scanner et les chemins HTTP à prendre en compte. L’essentiel de la configuration consiste à ajouter des éléments de personnalisation à différents niveaux.

  @Bean
  public GroupedOpenApi openApiMain() {
    return GroupedOpenApi.builder()
                .group("main")
                .packagesToScan("info.jtips")
                .pathsToMatch("/**")
                .addOpenApiCustomizer(openApi -> ...)
                .addOperationCustomizer((operation, handlerMethod) -> ...)
                .build();
  }

Tri de éléments

L’ordre des tags et des opérations à l’intérieur d’un tag peut être déterminé par des propriétés de configuration.

application.yml
springdoc:
  swagger-ui:
    tagsSorter: alpha
    operationsSorter: alpha
    docExpansion: none

Par contre l’ordre des schémas d’entrée et de sortie doit se faire par du code.

  @Bean
  public GroupedOpenApi openApiMain() {
    return GroupedOpenApi.builder()
              // Sort schemas
              .addOpenApiCustomizer(openApi -> {
                    Map<String, Schema> schemas = openApi.getComponents().getSchemas();
                    openApi.getComponents().setSchemas(new TreeMap<>(schemas));
              })
              ...
  }

Gestion d’erreurs

Si toutes les réponses d’erreurs retournent un contenu JSON du même format (Problem for HTTP APIs par exemple), on peut spécifier ce format globalement. Ça se fait en 2 étapes:

  • déclarer le schéma de retour,

  • enrichir les réponses d’erreur avec ce schéma.

  @Bean
  public GroupedOpenApi openApiMain() {
    this.errorResponseSchema = new Schema<>();
    this.errorResponseSchema.setName(ExceptionDTO.class.getSimpleName());
    this.errorResponseSchema.set$ref("#/components/schemas/" + ExceptionDTO.class.getSimpleName());

    return GroupedOpenApi.builder()
              .addOpenApiCustomizer(openApi -> {
                  // Register additional DTO schemas
                  openApi.getComponents().getSchemas().putAll(ModelConverters.getInstance().read(ExceptionDTO.class));
              })
              .addOperationCustomizer((operation, handlerMethod) -> {
                  operation.getResponses().forEach(this::enrichErrorResponse);
                  return operation;
              })
              ...
  }

  private void enrichErrorResponse(String code, ApiResponse apiResponse) {
    try {
      HttpStatus status = HttpStatus.resolve(Integer.parseInt(code));
      if (status != null && status.isError()) {
        apiResponse.content(
              new Content()
                  .addMediaType(APPLICATION_JSON_VALUE,
                                new MediaType().schema(errorResponseSchema)));
      }
    } catch (NumberFormatException ignored) {
      // if the code is not a number, we cannot enrich the response
    }
  }

Valeurs par défaut

Plutôt que de répéter la même description pour les réponses d’erreur, on peut récupérer la phrase associée au status et en faire la valeur par défaut.

  @Bean
  public GroupedOpenApi openApiMain() {
    return GroupedOpenApi.builder()
              .addOperationCustomizer((operation, handlerMethod) -> {
                  operation.getResponses().forEach(this::enrichAnyResponse);
                  return operation;
              })
              ...
  }

  private void enrichAnyResponse(String code, ApiResponse apiResponse) {
    try {
      HttpStatus status = HttpStatus.resolve(Integer.parseInt(code));
      if (status != null && apiResponse.getDescription() == null) {
        apiResponse.description(status.getReasonPhrase());
      }
    } catch (NumberFormatException ignored) {
      // if the code is not a number, we cannot enrich the response
    }
  }

Authentification

Il est possible d’utiliser (presque) exclusivement les propriétés de application.yml avec quelques annotations, mais comme je préfère le code, je n’en utilise que le strict minimum. Ici, c’est pour préciser qu’on utilise une authentification Oauth2 / OIDC, avec PKCE.

Cette étape n’est utile que pour utiliser Swagger UI pour tester l’API. Le mode d’essai peut d’ailleurs être désactivé (springdoc.swagger-ui.supported-submit-methods=[]). Et le bouton Authorize n’est affiché que si on a déclaré un SecurityScheme par personnalisation ou avec l’annotation @SecurityScheme.

  public GroupedOpenApi openApiMain() {
    this.oauth2Requirement = new SecurityRequirement().addList(SECURITY_NAME);

    return GroupedOpenApi.builder()
              .addOpenApiCustomizer(openApi ->
                  openApi.getComponents()
                         .addSecuritySchemes(SECURITY_NAME,
                            new SecurityScheme()
                                  .type(SecurityScheme.Type.OAUTH2)
                                  .flows(new OAuthFlows().authorizationCode(
                                              new OAuthFlow()
                                                    .authorizationUrl(properties.getIssuerUri() + "/oauth2/authorize")
                                                    .tokenUrl(properties.getIssuerUri() + "/oauth2/token")))))
              ...
  }
application.yml
springdoc:
  swagger-ui:
    oauth:
      use-pkce-with-authorization-code-grant: true
      client-id: console-ui
      client-secret: "xxxxxx"
Le client-secret est obligatoire car Swagger UI ne peut pas authentifier un utilisateur avec un client en mode none ; pour que ça fonctionne, j’ai ajouté un mode client_secret_post.

Ensuite, pour chaque endpoint sécurisé, on déclare un élément de sécurité par l’annotation @SecurityRequirement ou par personnalisation.

  public GroupedOpenApi openApiMain() {
    this.oauth2Requirement = new SecurityRequirement().addList(SECURITY_NAME);

    return GroupedOpenApi.builder()
              .addOpenApiCustomizer(openApi -> openApi.getPaths().forEach(this::securePath))
              ...
  }

  private void securePath(String key, PathItem pathItem) {
    if (key.startsWith("/secured")) {
      secureOperation(pathItem.getGet());
      secureOperation(pathItem.getPost());
      secureOperation(pathItem.getPut());
      secureOperation(pathItem.getPatch());
      secureOperation(pathItem.getDelete());
    }
  }

  private void secureOperation(Operation operation) {
    if (operation != null && operation.getSecurity() == null) {
      operation.addSecurityItem(oauth2Requirement);
    }
  }

Annotations personnalisées

En utilisant ces techniques de personnalisation, il devient facile de prendre en compte des annotations personnalisées.

2 exemples:

  • @ApiMultiTenant pour un endpoint qui nécessite un header de requête Tenant.

  @Bean
  public GroupedOpenApi openApiMain() {
    return GroupedOpenApi.builder()
              .addOperationCustomizer((operation, handlerMethod) -> {
                ApiMultiTenant multiTenant = handlerMethod.getMethodAnnotation(ApiMultiTenant.class);
                if (multiTenant != null
                    && (operation.getParameters() == null
                      || operation.getParameters()
                                  .stream()
                                  .noneMatch(parameter -> ParameterIn.HEADER.name().equals(parameter.getIn())
                                                       && TENANT_HEADER.equalsIgnoreCase(parameter.getName()))) {
                  operation.addParametersItem(
                    new HeaderParameter()
                      .name(TENANT_HEADER)
                      .required(multiTenant.required())
                      .example("00000000-0000-0000-0000-000000000000"));
                }
                return operation;
              })
              ...
  }
  • @ApiSecured pour un endpoint sécurisé qui peut donc répondre 401 ou 403.

  @Bean
  public GroupedOpenApi openApiMain() {
    ApiResponse response401 = new ApiResponse()
              .description(HttpStatus.UNAUTHORIZED.getReasonPhrase())
              .content(new Content().addMediaType(APPLICATION_JSON_VALUE, new MediaType().schema(errorResponseSchema)));
    ApiResponse response403 = new ApiResponse()
              .description(HttpStatus.FORBIDDEN.getReasonPhrase())
              .content(new Content().addMediaType(APPLICATION_JSON_VALUE, new MediaType().schema(errorResponseSchema)));
    return GroupedOpenApi.builder()
              .addOperationCustomizer((operation, handlerMethod) -> {
                ApiSecured secured = handlerMethod.getMethodAnnotation(ApiSecured.class);
                if (secured != null) {
                  operation.getResponses()
                           .addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()), response401)
                           .addApiResponse(String.valueOf(HttpStatus.FORBIDDEN.value()), response403);
                }
                return operation;
              })
              ...
  }