La librairie Springfox fournit des annotations permettant de générer automatiquement la documentation Swagger d’un projet Spring.

Par exemple :

    @ApiOperation("Calculate the distance (in meters) that a frisbee would travel")
    @ApiResponses({
            @ApiResponse(code = HttpServletResponse.SC_OK, message = "Distance (in meters)")
    })
    @GetMapping(value = "/frisbee/distance/{force}/{angle}")
    public FrisbeeThrowDTO getDistance(
            @ApiParam("The force (in Newtons) with which the disc was thrown")
            @PathVariable("force")
            final double force,
            @ApiParam("The angle-of-attack of the disc (in degrees)")
            @PathVariable("angle")
            final double angle
    ) throws InsufficientForceException, IllegalAngleException {
        double distance = frisbeeService.getDistance(force, angle);
        return new FrisbeeThrowDTO(force, angle, distance);
    }

Dans cet exemple, nous avons une opération qui calcule la distance que traverse un Frisbee (lancé avec des paramètres donnés) avant de toucher par terre. (NB : le vrai calcul est bien plus compliqué que ce qu’on montre ici !) Nous allons nous concentrer ici sur l’utilisation des annotations Springfox pour la génération de la documentation :

  • @ApiOperation : fournit des informations relatives à cette opération (verbe + chemin)
  • @ApiResponses : indique les codes HTTP de retour possibles
  • @ApiParam : documente les paramètres liés à l’opération (force et angle)

Il existe également des annotations pour documenter le DTO utilisé en retour :

@ApiModel(description = "Parameters of a single throw of a frisbee")
public class FrisbeeThrowDTO {
    @ApiModelProperty("The force (in Newtons) with which the frisbee was thrown")
    public double force;
    @ApiModelProperty("The angle-of-attack of the frisbee")
    public double angle;
    @ApiModelProperty("The distance the frisbee traveled before hitting the ground")
    public double distance;

    public FrisbeeThrowDTO(double force, double angle, double distance) {
        this.force = force;
        this.angle = angle;
        this.distance = distance;
    }
}
  • @ApiModel : fournit des informations relatives au type FrisbeeThrowDTO
  • @ApiModelProperty : explique les champs du type FrisbeeThrowDTO

Un petit inconvénient

Dans la méthode getDistance, on constate que deux Exceptions ont été déclarées : InsufficientForceException et IllegalAngleException. Ces deux Exceptions représentent des cas d’erreur maîtrisés : ça peut arriver dans une situation plus ou moins « normale » (par rapport à, e.g. OutOfMemoryError qui indique un autre souci en dehors du périmètre logique de la méthode).

La question est : comment documenter ces cas d’erreur connus ?

Une première approche serait d’attraper les Exceptions et d’agir en conséquence :

    @ApiOperation("Calculate the distance (in meters) that a frisbee would travel")
    @ApiResponses({
            @ApiResponse(code = HttpServletResponse.SC_OK,
                    message = "Distance (in meters)"),
            @ApiResponse(code = HttpServletResponse.SC_BAD_REQUEST,
                    message = "Not enough force was provided",
                    response = String.class),
            @ApiResponse(code = HttpServletResponse.SC_METHOD_NOT_ALLOWED,
                    message = "We don't know how to calculate with this angle",
                    response = String.class)
    })
    @GetMapping(value = "/frisbee/distance/{force}/{angle}")
    public ResponseEntity<?> getDistance(
            @ApiParam("The force (in Newtons) with which the disc was thrown")
            @PathVariable("force")
            final double force,
            @ApiParam("The angle-of-attack of the disc (in degrees)")
            @PathVariable("angle")
            final double angle
    ) {
        final double distance;
        
        try {
            distance = frisbeeService.getDistance(force, angle);
        } catch (InsufficientForceException e) {
            log.log(Level.SEVERE, "Error calculating distance", e);
            return ResponseEntity.badRequest().body(e.getMessage());
        } catch (IllegalAngleException e) {
            log.log(Level.SEVERE, "Error calculating distance", e);
            return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(e.getMessage());
        }
        
        return ResponseEntity.ok(new FrisbeeThrowDTO(force, angle, distance));
    }

Ici nous avons ajouté des @ApiResponses qui indiquent les codes de retour en cas d’erreur. Dans la méthode, on attrape les Exceptions puis on réagit en conséquence (on log le problème et répond avec un message d’erreur et le status HTTP adapté).

Cependant, cette méthode a plusieurs inconvénients :

  • On est obligé de se rappeler d’ajouter les @ApiResponse à chaque opération. Mais rien ne nous protège contre un développeur qui oublie de mettre à jour l’annotation lors d’une évolution future.
  • On est également obligé d’attraper chaque exception et d’agir en conséquence. En général, ce code va être quasiment identique à chaque fois. Du coup, nos contrôleurs seront pollués par beaucoup de « boilerplate » longs et répétitifs.
  • Le type de réponse (String) porte un message d’erreur. Si cette opération est consommée par une autre application (ex. React, Angular…), il sera difficile d’identifier le problème (par ex. mettre le champ « force » en rouge). Si le message d’erreur change, l’application devra évoluer.
    • Il est possible de contourner en jouant sur le code HTTP. Par exemple, on pourrait affecter un nouveau code 4xx pour chaque Exception. Mais cette approche viole les bonnes pratiques d’utilisation de code HTTP.

Automatiser la gestion d’erreur

Parlons d’abord du code : comment pourrait-on faire pour éviter d’ajouter des try-catch répétitifs à chaque méthode ?

Comme constaté plus haut, il y a 2 grands types d’erreur possibles pour n’importe quelle opération d’API :

  • Il y a d’abord les cas d’erreur connus, maîtrisés, « normaux » dans le sens où c’est le retour valide pour certains entrants. Ces erreurs se montrent sous forme d’Exception déclarée sur la méthode (idéalement, la bonne pratique est de créer vos propres classes d’Exception pour expliciter ces cas).
  • L’autre type est composé du monde noir des RuntimeException. Ce sont tous les soucis qui peuvent exister (perte de connexion à la base, plus d’espace disque, etc.) en dehors du cadre de la fonctionnalité.

Pourquoi c’est important ? Parce que, dans le premier cas, on connaît l’erreur et on peut donc fabriquer une réponse adaptée. Dans le deuxième cas, il est difficile de communiquer clairement le problème au client : à part mettre un message dans le log et remonter qu’il y avait un souci interne, on ne peut pas faire grande chose.

Cependant, dans les 2 cas, on voudrait que la réponse soit uniforme : un seul DTO pour communiquer, non seulement le message d’erreur, mais aussi un code d’erreur facilement identifiable par l’application cliente. (Il serait même possible de décliner cette notion avec plus de détail, mais pour cet exemple on va rester simple.)

On crée donc une classe ErrorDTO :

@ApiModel(description = "Information about an error returned from an API call")
public class ErrorDTO {
    public ErrorDTO() {}

    public ErrorDTO(final String code, final String description) {
        this.code = code;
        this.description = description;
    }

    @ApiModelProperty("A code that uniquely identifies the type of error")
    public String code;
    @ApiModelProperty("A technical description of the problem")
    public String description;
}

Spring fournit une annotation @ControllerAdvice qui permet d’ajouter de la logique à appliquer sur les contrôleurs de façon globale. Dans notre cas, il convient de créer une classe ayant une méthode annotée @ExceptionHandler pour gérer les Exceptions lancées par n’importe quelle opération.

Notre classe de gestion d’erreur ressemble donc à ceci. Notez que nous avons également ajouté une option pour pouvoir masquer le message d’erreur. Dans le contexte d’un site web publique, il serait peut-être préférable d’éviter la diffusion de cette information pour des raisons de sécurité. Nous avons aussi hérité de ResponseEntityExceptionHandler pour pouvoir intercepter les exceptions liées à la validation des arguments.

@ControllerAdvice
public class ExceptionResponseHandler extends ResponseEntityExceptionHandler {
    private final boolean debug;

    public ExceptionResponseHandler(@Value("${debug:false}")boolean debug) {
        this.debug = debug;
    }

    /**
     * This will be called when a Controller method throws anything
     * @param t The exception being thrown
     * @return The response that will be returned to the user
     */
    @ExceptionHandler(Throwable.class)
    public ResponseEntity<ErrorDTO> handleError(Throwable t) {
        final ErrorDTO errorDTO;
        final HttpStatus status;
        if (t instanceof APIException) {
            // This is a "normal" error: it's a valid return value from the API's contract
            errorDTO = new ErrorDTO(((APIException) t).getCode(), debug ? t.getMessage() : null);
            status = HttpStatus.BAD_REQUEST;
        } else {
            // This is an unexpected error: something is wrong that doesn't have anything to do
            // with the logic of the API operation
            errorDTO = new ErrorDTO("UNKNOWN", debug ? t.getMessage() : null);
            status = HttpStatus.INTERNAL_SERVER_ERROR;
        }

        return ResponseEntity.status(status).body(errorDTO);
    }

    /**
     * This will be called if the user passes, e.g. "abc" for the force (should be a number)
     */
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
        return ResponseEntity.badRequest().body(new ErrorDTO(
                "INVALID_ARGUMENT",
                ex.getMessage()
        ));
    }
}

Automatiser la documentation d’erreur

Il se trouve que Springfox fournit une alternative aux annotations pour alimenter le document Swagger générée : Nous pouvons intervenir lors du traitement à l’aide de plugins. En bref, à chaque phase de traitement des annotations et génération d’API, un plugin existe et permet de modifier programmatiquement le résultat.

Dans notre cas, il faut implémenter l’OperationBuilderPlugin. Celui-ci sert à fournir les informations relatives à une opération (verbe + route). Le principe d’un plugin Springfox est qu’à chaque étape, un context est alimenté : d’abord par Springfox, puis ensuite les plugins peuvent le modifier. On va donc utiliser l’OperationContext pour y ajouter nos cas d’erreur.

Commençons par le début. Rien de magique pour l’instant…

@Component
@Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER) // Required for Springfox plugins
public class ErrorOperationBuilder implements OperationBuilderPlugin {
    /**
     * Boilerplate method required by Springfox. Indicates if this plugin applies to the given
     * type of documentation (Swagger version).
     */
    @Override
    public boolean supports(DocumentationType delimiter) {
        return SwaggerPluginSupport.pluginDoesApply(delimiter);
    }

Le vrai travail commence en récupérant la liste des opérations que Springfox a déjà détectées.

Pour ce faire, il suffit de les injecter : elles sont déjà exposées comme @Bean ailleurs. On s’en servira pour extraire la liste des Exceptions déclarées sur chaque Method.

    /**
     * Map of [method name => declared Exceptions]
     */
    private final Map<String, Class<?>[]> nameToExceptions = new HashMap<>();

    public ErrorOperationBuilder(List<RequestMappingInfoHandlerMapping> handlerMappings) {
        saveMethodExceptions(handlerMappings);
    }

    /**
     * Collect the declared Exceptions for each handler mapping
     */
    private void saveMethodExceptions(List<RequestMappingInfoHandlerMapping> handlerMappings) {
        handlerMappings.forEach(reqMapInfoHandMap ->
                reqMapInfoHandMap.getHandlerMethods().values().forEach(handlerMethod -> {
                    final Method method = handlerMethod.getMethod();
                    nameToExceptions.put(method.getName(), method.getExceptionTypes());
                })
        );
    }

Ainsi équipé, nous sommes prêt à agrémenter le context avec les retours d’erreur.

Le principe est simple : pour l’opération donnée, s’il existe des Exceptions, nous ajoutons un ResponseMessage (l’équivalent de ce que contient l’annotation @ApiResponse). Il y a une petite subtilité : pour les Exceptions « inattendues » (i.e. qui n’héritent pas d’ApiException), on utilise le code statut HTTP 500 (Internal Server Error).

    /**
     * Run the plugin.
     */
    @Override
    public void apply(OperationContext context) {
        final Class<?>[] exceptions = nameToExceptions.get(context.getName());

        if(exceptions != null) {
            // Here, we add more responses (what would normally be done using the @ApiResponses annotation)
            final List<ExceptionErrorCode> errorCodes = Arrays
                    .stream(exceptions)
                    .map(ExceptionErrorCode::of)
                    .collect(Collectors.toList());
            context.operationBuilder().responseMessages(buildResponseMessage(errorCodes));
        }
    }

    /**
     * Build error responses for the given Exceptions.
     */
    private static Set<ResponseMessage> buildResponseMessage(List<ExceptionErrorCode> errorCodes) {
        Set<ResponseMessage> responses = new HashSet<>();

        // UNKNOWN errors are treated as Internal Server Errors. Since this is always possible,
        // we always add it as a possible result.
        responses.add(new ResponseMessage(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "An unexpected problem occurred. See the server logs for details.",
                new ModelRef(ErrorDTO.class.getSimpleName()),
                Collections.emptyMap(),
                Collections.emptyList()
        ));

        // This string is what will actually be displayed in the Swagger description.
        final String errors = errorCodes.stream()
                .filter(e -> e != ExceptionErrorCode.UNKNOWN)
                .map(Enum::name)
                .sorted()
                .collect(Collectors.joining("\n"));

        if(!StringUtils.isEmpty(errors)) {
            responses.add(new ResponseMessage(
                    HttpStatus.BAD_REQUEST.value(),
                    "One of the following errors occurred:\n" + errors,
                    new ModelRef(ErrorDTO.class.getSimpleName()),
                    Collections.emptyMap(),
                    Collections.emptyList()));
        }

        return responses;
    }

Avec cette configuration, on obtient le résultat suivant en allant sur le portail Swagger :

N’hésitez-pas à récupérer le projet d’exemple disponible sur notre Github : https://github.com/ineat/springfox-plugin-demo