Des messages d'exceptions internationalisés avec Spring

Il y a maintenant quelques mois, avec une équipe Ippon, nous avons commencé un projet où nous étions en charge de la réalisation d'APIs qui allaient être consommées, avant tout, par une application mobile réalisée par une autre société.

Lors du premier échange avec "l'équipe mobile", une de leurs demandes les plus importantes était d'avoir des messages d'erreur déjà traduits.

De prime abord cette demande semble étrange mais, en fait, elle est tout à fait logique : on ne peut pas mettre facilement à jour une application mobile sur tous les appareils. Maintenir des messages à jour aurait donc demandé le développement d'un service spécifique. De plus, avoir deux équipes pour maintenir ces traductions augmente drastiquement le risque d'avoir des messages manquants (et donc l'affichage de fallbacks techniques).

En ayant les messages d'erreur sur le serveur qui renvoie ces erreurs il est tout à fait possible de s'assurer que toutes les erreurs sont traduites. C'est un avantage indéniable en terme de qualité ressentie d'une application (et, comme nous allons le voir, ça ne coûte pas très cher à mettre en place).

Depuis cette réalisation je réutilise des versions adaptées de la gestion d'erreur mise en place sur ce projet (parce que j'en suis plutôt satisfait).

Dans cet article je vais décrire les principes de base de cette mise en place mais vous pouvez allez voir ce repository pour avoir un exemple plus complet !

Pour faire cet exemple je me suis retrouvé confronté au problème le plus commun lorsqu'on fait du développement : le nommage (dans ce cas celui de l'application d'exemple). Comme je n'avais pas vraiment d'idée d'application pouvant faire un bon exemple elle s'appellera pouet (parce que c'est un peu marrant).

Why so serious?

Comme pour toute réalisation on peut se demander si le jeu en vaut vraiment la chandelle. Ici, la version initiale de cette gestion d'erreur m'a pris 1.5J d'étude et de réalisation (de mémoire parce que l'intégration de l'internationalisation et de Bean Validation n'ont pas été gentilles). C'est vraiment BEAUCOUP !

C'est un coût d'autant plus important qu'il existe déjà pléthore de mécanismes (on pense bien sûr à problems de zalando) mais je n'en ai pas trouvé qui répondait à mon besoin du moment :

  • Avoir des clés techniques pour chaque erreur (pour déclencher des traitements spécifiques côté front dans certains cas) ;
  • Avoir des traductions pour toutes les erreurs (en étant certain de bien avoir la traduction) ;
  • Pouvoir remplacer des placeholders dans les messages renvoyés aux utilisateurs ;
  • Pouvoir créer simplement des exceptions spécialisées propres au traitement métier ;
  • Pouvoir uniformiser les retours des erreurs (pour faciliter leur prise en compte côté front) ;
  • Pouvoir uniformiser le log de ces erreurs (enfin, être certain d'avoir une trace des erreurs intéressantes).

Mettre en place une gestion d'erreurs répondant à ces besoins, pour un coût le plus réduit possible au quotidien, était une bonne idée ! Gérer correctement les erreurs est au moins aussi important que de traiter correctement les cas nominaux.

En fait, on pense toujours au cas nominaux mais ce ne sont pas ces cas qui m'inquiètent au quotidien. Je suis bien plus préoccupé par les cas aux bornes et par les cas d'erreurs !

Vous allez le voir, la solution proposée ici a un coût, déjà pour sa mise en place mais ensuite pour la définition des erreurs (il faut bien les écrire ces messages traduits). C'est cependant un coût que je paie volontier tant il apporte du confort à tous le monde !

Les clés des messages

Lorsqu'on parle de traduction, la première chose à définir est la clé des messages. Ici, comme je veux avoir un mécanisme pour m'assurer de bien avoir traduit tous mes messages je décide de définir une interface pour ces clés :

package com.ippon.pouet.common.domain.error;
 
import java.io.Serializable;
 
@FunctionalInterface
public interface PouetMessage extends Serializable {
  String getMessageKey();
}
J'ai l'habitude de faire des architectures hexagonales sur mes projets, ce qui explique le package de cette interface mais vous pouvez la placer où bon vous semble.

J'aurais pu définir toutes les clés dans un unique enum mais il aurait eu très peu de cohérence (en mélangeant les messages de toute l'application).

Pour m'assurer que tous mes messages sont des enums mais aussi que toutes les clés sont bien définies dans l'internationalisation j'ajoute un test se basant sur la librairie reflections :

<dependency>
  <groupId>org.reflections</groupId>
  <artifactId>reflections</artifactId>
  <scope>test</scope>
</dependency>
import static org.assertj.core.api.Assertions.*;
 
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
 
import org.junit.jupiter.api.Test;
import org.reflections.Reflections;
import org.reflections.scanners.SubTypesScanner;
import org.reflections.util.ClasspathHelper;
import org.reflections.util.ConfigurationBuilder;
import org.reflections.util.FilterBuilder;
 
import com.ippon.pouet.common.domain.error.PouetMessage;
 
public class ErrorMessagesUnitTest {
    private static final String BASE_PACKAGE = "com.ippon.pouet";
 
  private static final Set<Class<? extends PouetMessage>> errors = new Reflections(
      new ConfigurationBuilder()
          .setUrls(ClasspathHelper.forPackage(BASE_PACKAGE))
          .setScanners(new SubTypesScanner())
          .filterInputsBy(new FilterBuilder().includePackage(BASE_PACKAGE)))
              .getSubTypesOf(PouetMessage.class);
 
  @Test
  public void shouldHaveOnlyEnumImplementations() {
    errors
        .forEach(error -> assertThat(error.isEnum() || error.isInterface())
            .as("Implementations of " + PouetMessage.class.getName()
                + " must be enums and " + error.getName() + " wasn't")
            .isTrue());
  }
 
  @Test
  public void shouldHaveMessagesForAllKeys() {
    Collection<Properties> messages = loadMessages();
 
    errors.stream()
        .filter(error -> error.isEnum())
        .forEach(error -> Arrays.stream(error.getEnumConstants())
            .forEach(value -> messages.forEach(assertMessageExists(value))));
  }
 
  private List<Properties> loadMessages() {
    try {
      return Files.list(Paths.get("src/main/resources/i18n"))
          .map(toProperties())
          .collect(Collectors.toList());
    } catch (IOException e) {
      throw new AssertionError();
    }
  }
 
  private Function<Path, Properties> toProperties() {
    return file -> {
      Properties properties = new Properties();
      try {
        properties.load(Files.newInputStream(file));
      } catch (IOException e) {
        throw new AssertionError();
      }
 
      return properties;
    };
  }
 
  private Consumer<Properties> assertMessageExists(PouetMessage value) {
    return currentMessages -> {
      assertThat(
          currentMessages.getProperty("pouet.error." + value.getMessageKey()))
              .as("Can't find message for " + value.getMessageKey()
                  + " in all files, check your configuration")
              .isNotBlank();
    };
  }
}

Plusieurs points d'attention ici :

  • Il faut bien penser à changer la valeur de BASE_PACKAGE pour que cela corresponde au package de base de votre application.
  • Je m'attends à avoir les messages dans "src/main/resources/i18n", il faut changer ce chemin si vous voulez définir vos messages dans d'autres fichiers.
  • Ici, j'ai fait le choix d'ajouter le préfixe "pouet.error." à mes messages dans les fichiers i18n, vous pouvez changer de préfixe ou ne pas en mettre du tout !

Ce premier outil va nous permettre de définir toutes les clés de messages que l'on souhaite tout en étant certain de bien avoir les traductions !

Afin de faciliter la définition des messages d'erreur j'ai pour habitude de les préfixer par "user." ou "server.". Selon votre besoin vous pouvez même imaginer des interfaces dédiées en fonction des "familles" d'erreur.

Les types d'erreurs

Dès lors qu'on traite des erreurs il faut pouvoir catégoriser leurs types. Quand on fait des APIs REST on a tendance à calquer ces types sur les codes HTTP que l'on utilise, c'est ce que je fais :

package com.ippon.pouet.common.domain.error;
 
public enum ErrorStatus {
  BAD_REQUEST,
  UNAUTHORIZED,
  FORBIDDEN,
  NOT_FOUND,
  INTERNAL_SERVER_ERROR
}
J'ai fait le choix de ne pas mettre les codes HTTP dans cet enum. Je devrais donc faire le mapping plus tard. L'idée est de ne pas totalement lier ces statuts d'erreur à HTTP pour garder une certaine souplesse en vue de l'utilisation d'autres protocoles.

Les exceptions

Il y a de nombreux choix à faire pour définir les exceptions de notre solution. Après tout, elles vont être utilisées très souvent ! Elles doivent donc être à la fois pratiques à utiliser et suffisamment précises pour apporter les informations nécessaires aux utilisateurs et aux développeurs.

Voici les principaux choix que j'ai fait :

  • Les exceptions seront des RuntimeExceptions. Au quotidien je préfère largement manipuler des unchecked exceptions d'autant que je vais mettre en place une mécanique permettant de toutes les traiter de la même manière.
  • Je veux pouvoir utiliser un builder pour créer mes exceptions mais je veux aussi pouvoir les étendre pour créer des instances spécifiques plus simples à utiliser.
  • Je veux pouvoir créer une nouvelle exception même sans aucune information.

J'en arrive donc à cette implémentation :

package com.ippon.pouet.common.domain.error;
 
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
 
/**
 * Parent exception used in Pouet application. Those exceptions will be resolved as human readable errors.
 *
 * <p>
 * You can use this implementation directly:
 * </p>
 *
 * <p>
 * <code>
 *     <pre>
 *       PouetException.builder(StandardMessages.USER_MANDATORY)
 *          .argument("key", "value")
 *          .argument("other", "test")
 *          .status(ErrorsHttpStatus.BAD_REQUEST)
 *          .message("Error message")
 *          .cause(new RuntimeException())
 *          .build();
 *     </pre>
 *   </code>
 * </p>
 *
 * <p>
 * Or make exceptions:
 * </p>
 *
 * <p>
 * <code>
 *     <pre>
 *       public class MissingMandatoryValueException extends PouetException {
 *
 *         public MissingMandatoryValueException(PouetMessage pouetMessage, String fieldName) {
 *           this(builder(pouetMessage, fieldName, defaultMessage(fieldName)));
 *         }
 *
 *         protected MissingMandatoryValueException(PouetExceptionBuilder builder) {
 *           super(builder);
 *         }
 *
 *         private static PouetExceptionBuilder builder(PouetMessage pouetMessage, String fieldName, String message) {
 *           return PouetException.builder(pouetMessage)
 *               .status(ErrorsStatus.INTERNAL_SERVER_ERROR)
 *               .argument("field", fieldName)
 *               .message(message);
 *         }
 *
 *         private static String defaultMessage(String fieldName) {
 *           return "The field \"" + fieldName + "\" is mandatory and wasn't set";
 *         }
 *       }
 *     </pre>
 *   </code>
 * </p>
 */
public class PouetException extends RuntimeException {
  private final PouetMessage pouetMessage;
  private final ErrorStatus status;
  private final Map<String, String> arguments;
 
  protected PouetException(PouetExceptionBuilder builder) {
    super(getMessage(builder), getCause(builder));
    arguments = getArguments(builder);
    status = getStatus(builder);
    pouetMessage = getPouetMessage(builder);
  }
 
  private static String getMessage(PouetExceptionBuilder builder) {
    if (builder == null) {
      return null;
    }
 
    return builder.message;
  }
 
  private static Throwable getCause(PouetExceptionBuilder builder) {
    if (builder == null) {
      return null;
    }
 
    return builder.cause;
  }
 
  private static Map<String, String> getArguments(
      PouetExceptionBuilder builder) {
    if (builder == null) {
      return null;
    }
 
    return Collections.unmodifiableMap(builder.arguments);
  }
 
  private static ErrorStatus getStatus(PouetExceptionBuilder builder) {
    if (builder == null) {
      return null;
    }
 
    return builder.status;
  }
 
  private static PouetMessage getPouetMessage(PouetExceptionBuilder builder) {
    if (builder == null) {
      return null;
    }
 
    return builder.pouetMessage;
  }
 
  public static PouetExceptionBuilder builder(PouetMessage message) {
    return new PouetExceptionBuilder(message);
  }
 
  public Map<String, String> getArguments() {
    return arguments;
  }
 
  public ErrorStatus getStatus() {
    return status;
  }
 
  public PouetMessage getPouetMessage() {
    return pouetMessage;
  }
 
  public static class PouetExceptionBuilder {
    private final Map<String, String> arguments = new HashMap<>();
    private String message;
    private ErrorStatus status;
    private PouetMessage pouetMessage;
    private Throwable cause;
 
    public PouetExceptionBuilder(PouetMessage pouetMessage) {
      this.pouetMessage = pouetMessage;
    }
 
    public PouetExceptionBuilder argument(String key, Object value) {
      arguments.put(key, getStringValue(value));
 
      return this;
    }
 
    private String getStringValue(Object value) {
      if (value == null) {
        return "null";
      }
 
      return value.toString();
    }
 
    public PouetExceptionBuilder status(ErrorStatus status) {
      this.status = status;
 
      return this;
    }
 
    public PouetExceptionBuilder message(String message) {
      this.message = message;
 
      return this;
    }
 
    public PouetExceptionBuilder cause(Throwable cause) {
      this.cause = cause;
 
      return this;
    }
 
    public PouetException build() {
      return new PouetException(this);
    }
  }
}

Jetons un oeil aux attributs :

  • message: c'est le message "technique" de l'exception, celui qui sera renseigné dans RuntimeException ;
  • cause: exception ayant déclenché notre exception, là aussi renseigné dans RuntimeException ;
  • pouetMessage: la clé qui me permettra la récupération du message d'erreur traduit ;
  • status: l'ErrorStatus que je traduirai en fonction du protocole renvoyant cette exception ;
  • arguments: des clés et valeurs qui viendront remplacer des placeholders dans les messages.

Cette implémentation me permet une grande souplesse d'utilisation. Le plus souvent je vais créer des exceptions spécialisées (parfois avec des static factories) :

package com.ippon.pouet.common.domain.error;
 
public class MissingMandatoryValueException extends PouetException {
 
  private MissingMandatoryValueException(PouetExceptionBuilder builder) {
    super(builder);
  }
 
  public static MissingMandatoryValueException forNullValue(String fieldName) {
    return new MissingMandatoryValueException(
        builder(StandardMessage.SERVER_MANDATORY_NULL, fieldName,
            defaultMessage(fieldName) + " (null)"));
  }
 
  public static MissingMandatoryValueException forBlankValue(String fieldName) {
    return new MissingMandatoryValueException(
        builder(StandardMessage.SERVER_MANDATORY_BLANK, fieldName,
            defaultMessage(fieldName) + " (blank)"));
  }
 
  private static PouetExceptionBuilder builder(PouetMessage portailProMessage,
      String fieldName, String message) {
    return PouetException.builder(portailProMessage)
        .status(ErrorStatus.INTERNAL_SERVER_ERROR)
        .argument("field", fieldName)
        .message(message);
  }
 
  private static String defaultMessage(String fieldName) {
    return "The field \"" + fieldName + "\" is mandatory and wasn't set";
  }
}
Les choix faits ici sont à adapter à vos besoins, n'hésitez pas à designer vos exceptions pour répondre spécifiquement aux besoins de votre contexte !

La transformation en réponses traduites

Maintenant que j'ai mes exceptions je peux les traduire pour les utilisateurs. Dans un premier temps je veux renvoyer un code "technique" de l'erreur et le message à afficher aux utilisateurs. Pour ce faire je vais définir un @ControllerAdvice :

package com.ippon.pouet.common.infrastructure.primary;
 
import java.util.Locale;
import java.util.Map;
 
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
 
import com.ippon.pouet.common.domain.error.ErrorStatus;
import com.ippon.pouet.common.domain.error.PouetException;
import com.ippon.pouet.common.domain.error.PouetMessage;
import com.ippon.pouet.common.domain.error.StandardMessage;
 
@ControllerAdvice
public class PouetErrorHandler {
  private static final String MESSAGE_PREFIX = "pouet.error.";
  private static final String DEFAULT_KEY = StandardMessage.INTERNAL_SERVER_ERROR
      .getMessageKey();
  private static final String BAD_REQUEST_KEY = StandardMessage.BAD_REQUEST
      .getMessageKey();
 
  private final MessageSource messages;
 
  public PouetErrorHandler(MessageSource messages) {
    Locale.setDefault(Locale.FRANCE);
    this.messages = messages;
  }
 
  @ExceptionHandler
  public ResponseEntity<PouetError> handlePouetException(
      PouetException exception) {
    HttpStatus status = getStatus(exception);
 
    String messageKey = getMessageKey(status, exception);
    PouetError error = new PouetError(messageKey,
        getMessage(messageKey, exception.getArguments()));
 
    return new ResponseEntity<>(error, status);
  }
 
  private HttpStatus getStatus(PouetException exception) {
    ErrorStatus status = exception.getStatus();
    if (status == null) {
      return HttpStatus.INTERNAL_SERVER_ERROR;
    }
 
    switch (status) {
    case BAD_REQUEST:
      return HttpStatus.BAD_REQUEST;
    case UNAUTHORIZED:
      return HttpStatus.UNAUTHORIZED;
    case FORBIDDEN:
      return HttpStatus.FORBIDDEN;
    case NOT_FOUND:
      return HttpStatus.NOT_FOUND;
    default:
      return HttpStatus.INTERNAL_SERVER_ERROR;
    }
  }
 
  private String getMessageKey(HttpStatus status, PouetException exception) {
    PouetMessage message = exception.getPouetMessage();
    if (message == null) {
      return getDefaultMessage(status);
    }
 
    return message.getMessageKey();
  }
 
  private String getDefaultMessage(HttpStatus status) {
    if (status.is5xxServerError()) {
      return DEFAULT_KEY;
    }
 
    return BAD_REQUEST_KEY;
  }
 
  private String getMessage(String messageKey, Map<String, String> arguments) {
    String text = getMessageFromSource(messageKey);
 
    return ArgumentsReplacer.replaceArguments(text, arguments);
  }
 
  private String getMessageFromSource(String messageKey) {
    Locale locale = LocaleContextHolder.getLocale();
 
    try {
      return messages.getMessage(MESSAGE_PREFIX + messageKey, null, locale);
    } catch (NoSuchMessageException e) {
      return messages.getMessage(MESSAGE_PREFIX + DEFAULT_KEY, null, locale);
    }
  }
}

Plusieurs choses à noter ici :

  • Je force la locale à france dans le constructeur. Je n'ai cependant pas de fichier de messages "*_fr". L'idée c'est de mettre les messages en français dans mon fichier de locale par défaut. C'est donc cette locale qui sera utilisée par défaut. Si un header "Accept-Language" est envoyé il sera intercepté par Spring et c'est cette locale qui sera renvoyée par LocaleContextHolder.getLocale().
  • Le choix de ne pas mettre le code HTTP correspondant au statut de l'erreur m'oblige à faire la traduction ici. Encore une fois c'est un choix, en fonction de votre architecture et de vos besoins vous pouvez tout à fait associer directement les codes HTTP.
  • J'ai choisi de faire la récupération des messages de manière optimiste (et de traiter les cas d'erreur avec un message par défaut). Le choix est discutable ! N'hésitez pas à faire autrement !
Je suis parti d'une application JHipster pour faire cette application de démonstration. J'ai dû supprimer les fichiers i18n de test et adapter certaines exceptions JHipster pour ne pas entraîner de régressions.

Allons plus loin

Dans la version présentée précédemment seules les PouetException sont traitées et rendues comme des PouetError. On peut cependant aller plus loin :

  • En gérant aussi les logs de manière centralisée dans le handler ;
  • En traitant aussi les exceptions de Spring ;
  • En normalisant aussi les retours de Bean validation (et donc en renseignant les informations de validation des champs dans le retour).

C'est ce qui est fait dans le handler de l'application d'exemple, n'hésitez à prendre ce dont vous avez besoin !