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.
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.
Certaines annotations d’OpenAPI v3 ont le même nom qu’en Swagger v2, il faut donc être attentif aux imports: |
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
.
|
@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.
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.
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")))))
...
}
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êteTenant
.
@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;
})
...
}