Problem for HTTP API avec Spring

Cette RFC permet d’uniformiser la façon de répondre en cas d’erreur, avec un contenu plus riche que le status code. Le contenu des réponse peut être en JSON ou XML ; je vais me focaliser sur la variante JSON.

RFC 7807

Media type: application/problem+json

attributs:

  • type (URI): type du problème, référence à une documentation lisible par un humain

  • title (texte): titre court, lisible par un humain, éventuellement traduit

  • status (nombre): status code HTTP

  • detail (texte): description détaillée

  • instance (URI): référence pour l’occurence du problème

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en

{
  "type": "https://example.com/probs/out-of-credit",
  "title": "You do not have enough credit.",
  "detail": "Your current balance is 30, but that costs 50.",
  "instance": "/account/12345/msgs/abc",
}

La spécification est souple pour l’ajout d’attributs supplémentaires.

Intégration dans Spring

Avec Spring Boot 2 et Spring Framework 3, l’intégration se faisait via la librairie tierce Zalando Problem. Celle-ci n’est pas compatible avec Spring Boot 3. Spring Framework 6 (et Spring Boot 3) supporte nativement des fonctionnalités similaires.

ExceptionHandler par défaut

Pour gérer les exceptions, on fait des @ExceptionHandler. Ici, on fait la même chose. Pour faciliter le travail, on peut implémenter un gestionnaire d’exception qui hérite de ResponseEntityExceptionHandler.

@RestControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {
}

La classe de base gère une bonne vingtaine d’exceptions.

On peut ajouter des informations personnalisées en redéfinissant la méthode createResponseEntity(…​), ou handleExceptionInternal(…​). L’idée c’est enrichir le corps de la réponse, qui est probablement de type ProblemDetail.

  • ProblemDetail

    • type: URI

    • title: String

    • status: int

    • detail: String

    • instance: URI

    • properties: Map<String,Object>

  protected ResponseEntity<Object> createResponseEntity(
			@Nullable Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
    HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
    if (body instanceof ProblemDetail problem) {
      problem.setInstance(URI.create(servletRequest.getRequestURI()));
      problem.setProperty("cid", CorrelationIdInterceptor.getCorrelationId());
    }

    return new ResponseEntity<>(problem, headers, statusCode);
  }

Erreurs personnalisées

On peut aussi ajouter des handlers pour nos propres exceptions dans la même classe.

@RestControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {

  @ExceptionHandler
  public ProblemDetail handleTechnicalException(TechnicalException exception, WebRequest request) {
	  ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, exception.getMessage());
	  return handleExceptionInternal(exception, body, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request);
  }

  @ExceptionHandler
  public ProblemDetail handleBusinessException(BusinessException exception, WebRequest request) {
	  ProblemDetail body = ProblemDetail.forStatusAndDetail(exception.getStatus(), exception.getMessage());
	  return handleExceptionInternal(exception, body, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request);
  }

}

i18n

ErrorResponse sert à ça, avec l’utilisation d’un MessageSource. Pas mal d’exceptions de Spring implémentent ErrorResponse.

  • ErrorResponse

    • getBody(): ProblemDetail

    • getTitleMessageCode(): String

    • getDetailMessageCode(): String

    • getDetailMessageArguments(): String

    • getHeaders(): HttpHeaders

    • getStatusCode(): HttpStatusCode

Au lieu de spécifier un titre et une description détaillées, on passe des codes, du type 'problemDetail.title.unknown-product' et 'problemDetail.detail.unknown-product'. La recherche des vrais messages se fait pas l’intermédiaire d’un MessageSource, avec la locale comme paramètre.

La classe ErrorResponseException implémente cette interface et peut servir de base aux exceptions personnalisées.

Dans ResponseEntityExceptionHandler, les instances de l’interface sont utilisées comme intermédiaire pour construrire une instance de ProblemDetail. Mais une méthode annotée en @ExceptionHandler peut aussi renvoyer directement un objet de type ErrorResponse.

Ça peut faire du code très léger!

  @ExceptionHandler
  public ErrorResponse handleCustomException(CustomException exception) {
	  return exception;
  }

L’instance de MessageSource est enregistrée dans ResponseEntityExceptionHandler.

@RestControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {

  @PostConstruct
  public void init() {
    ReloadableResourceBundleMessageSource messageSource
          = new ReloadableResourceBundleMessageSource();
    messageSource.setBasename("classpath:problem");
    messageSource.setDefaultEncoding("UTF-8");

    this.setMessageSource(messageSource);
  }

  ...

}

Il ne reste plus qu’à ajouter les messages dans les fichiers properties…​

Solutions alternatives

Il existe des solutions alternatives sous forme de librairies tierces.

Zalando Problems for Spring

C’est la solution historique, qui était déjà utilisable avec Spring Boot 2. Après quelques hésitation, elle support Spring Boot 3.

Spting Boot Problem Handler

C’est tout nouveau, pour Spring Boot 3. Je n’ai pas encore testé.