Construire une API REST avec Spring Boot : validation, erreurs et tests (partie 2/2)

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
14 min de lecture
16 vues

Écouter l'article

Série « Construire une API REST avec Spring Boot » : partie 2 sur 2.

Au sommaire de la série :

  1. Du contrôleur au service
  2. Validation, erreurs et tests (cette partie)

Partie précédente : Du contrôleur au service

La première partie a construit l'API de gestion de tâches jusqu'au service : conventions REST, DTO, entité, TaskController, TaskService et exceptions métier. Cette deuxième partie traite de ce qui rend l'API fiable pour ses clients : la validation des entrées, la gestion globale des erreurs au format ProblemDetail, la pagination, puis les tests et l'appel de l'API. Les imports sont toujours inclus, et le code a été compilé et exécuté avec Spring Boot 3.5.

5. La validation

Jakarta Validation (le successeur du paquet javax.validation, fourni par spring-boot-starter-validation) décrit les contraintes sur les composants des DTO :

  • @NotBlank refuse null, la chaîne vide et la chaîne faite d'espaces ; elle ne s'applique qu'aux chaînes ;
  • @NotNull refuse seulement null, ce qui convient aux énumérations et aux objets ;
  • @Size(max = 120) borne la longueur, et laisse passer null : une description absente est valide.

Chaque contrainte accepte un message. Les écrire dans la langue de l'API évite d'exposer les messages par défaut de Hibernate Validator. Pour de l'internationalisation, on passe par des clés ({task.title.required}) et un fichier ValidationMessages.properties.

La validation ne s'exécute que si l'argument est annoté @Valid (ou @Validated). Sans cette annotation, @NotBlank est ignorée sans erreur, ce qui fait partie des oublis qu'un test de contrôleur attrape rapidement. Quand une contrainte est violée, Spring lève une MethodArgumentNotValidException avant même d'entrer dans la méthode du contrôleur. C'est cette exception que la section suivante transforme en réponse 400.

Les règles qui ne s'expriment pas avec une annotation (une échéance non passée, un titre unique) restent dans le service : elles dépendent de l'heure ou de la base, pas seulement de la forme de la requête.

6. La gestion globale des erreurs

Sans configuration particulière, une exception levée dans un contrôleur donne la page d'erreur par défaut de Spring Boot, ou un 500 avec une structure peu utile. On souhaite à la place un format unique pour toutes les erreurs. La RFC 9457 (qui remplace la RFC 7807) définit ce format sous le type de média application/problem+json, avec les champs type, title, status, detail et instance. Spring Framework 6 le fournit avec la classe ProblemDetail (présente dès Spring Framework 6.0, donc Spring Boot 3.0), dont on lit les valeurs avec getStatus(), getTitle(), getDetail(), getType() et getProperties(), et dans laquelle setProperty(nom, valeur) ajoute des champs propres à l'API.

@RestControllerAdvice déclare une classe dont les méthodes @ExceptionHandler s'appliquent à tous les contrôleurs. La classe ci-dessous étend ResponseEntityExceptionHandler, qui traite déjà les exceptions de Spring MVC (JSON illisible, paramètre de mauvais type, méthode non supportée, ResponseStatusException) et les renvoie au format ProblemDetail. Il ne reste qu'à ajouter les exceptions métier du projet, à personnaliser le traitement de la validation et à fermer la porte avec un traitement générique.

package com.example.tasks.exception;

import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    public record FieldViolation(String field, String message) {
    }

    @ExceptionHandler(TaskNotFoundException.class)
    public ProblemDetail handleNotFound(TaskNotFoundException ex) {
        return problem(HttpStatus.NOT_FOUND, "Tâche introuvable", ex.getMessage(), "TASK_NOT_FOUND");
    }

    @ExceptionHandler(TaskConflictException.class)
    public ProblemDetail handleConflict(TaskConflictException ex) {
        return problem(HttpStatus.CONFLICT, "Conflit", ex.getMessage(), "TASK_CONFLICT");
    }

    @ExceptionHandler(InvalidTaskException.class)
    public ProblemDetail handleInvalidTask(InvalidTaskException ex) {
        return problem(HttpStatus.UNPROCESSABLE_ENTITY, "Règle métier non respectée", ex.getMessage(),
                "TASK_RULE_VIOLATION");
    }

    @ExceptionHandler(InvalidSortException.class)
    public ProblemDetail handleInvalidSort(InvalidSortException ex) {
        return problem(HttpStatus.BAD_REQUEST, "Tri invalide", ex.getMessage(), "INVALID_SORT");
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        List<FieldViolation> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(error -> new FieldViolation(error.getField(), error.getDefaultMessage()))
                .toList();
        ProblemDetail body = problem(HttpStatus.BAD_REQUEST, "Requête invalide",
                "Un ou plusieurs champs sont invalides.", "VALIDATION_FAILED");
        body.setProperty("errors", errors);
        return createResponseEntity(body, headers, status, request);
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex) {
        log.error("Erreur non gérée", ex);
        return problem(HttpStatus.INTERNAL_SERVER_ERROR, "Erreur interne",
                "Une erreur est survenue. Réessayez plus tard.", "INTERNAL_ERROR");
    }

    private static ProblemDetail problem(HttpStatus status, String title, String detail, String code) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setTitle(title);
        problem.setProperty("code", code);
        return problem;
    }
}

Un handler qui renvoie un ProblemDetail n'a pas besoin de ResponseEntity : Spring prend le statut dans problem.getStatus(). La méthode surchargée, elle, doit respecter la signature de la classe parente, qui renvoie un ResponseEntity<Object> ; createResponseEntity construit la réponse avec le bon type de média.

La réponse à GET /api/v1/tasks/99 ressemble alors à ceci :

{
  "type": "about:blank",
  "title": "Tâche introuvable",
  "status": 404,
  "detail": "La tâche 99 n'existe pas.",
  "instance": "/api/v1/tasks/99",
  "code": "TASK_NOT_FOUND"
}

Et un POST avec un titre vide :

{
  "type": "about:blank",
  "title": "Requête invalide",
  "status": 400,
  "detail": "Un ou plusieurs champs sont invalides.",
  "instance": "/api/v1/tasks",
  "code": "VALIDATION_FAILED",
  "errors": [
    { "field": "title", "message": "Le titre est obligatoire" }
  ]
}

Le client sait quels champs corriger sans analyser un texte libre. Le champ code donne un identifiant stable que l'interface peut associer à son propre message ; le title et le detail sont destinés aux humains et peuvent évoluer. Les exceptions standard de Spring MVC (JSON illisible, méthode non supportée, paramètre de mauvais type) sortent elles aussi en ProblemDetail, mais sans le champ code : un client doit donc le traiter comme facultatif.

Le traitement générique. Le dernier handler attrape tout ce qui n'a pas été prévu. Il écrit l'exception complète dans les logs, avec sa trace, et renvoie au client un message fixe. On ne met jamais ex.getMessage() dans la réponse d'un 500 : ce texte peut contenir une requête SQL, un chemin de fichier ou un nom de table. Un identifiant de corrélation (placé dans les logs et dans la réponse) aide à relier les deux quand un utilisateur signale un problème ; il n'est pas inclus ici pour garder l'exemple court.

Ce handler a un effet de bord à connaître : comme il attrape Exception, il capte aussi les exceptions que d'autres composants voulaient traiter eux-mêmes. Avec Spring Security, un AccessDeniedException levé par une annotation de méthode atteindrait ce handler et deviendrait un 500 au lieu d'un 403. Il faut alors ajouter un handler dédié, ou laisser ces exceptions remonter vers le filtre de sécurité. La série sur Spring MVC montre ces handlers.

Spring Boot propose aussi la propriété spring.mvc.problemdetails.enabled=true, qui active ce format pour les exceptions de Spring MVC sans écrire de classe. L'auto-configuration correspondante est conditionnée par l'absence de bean ResponseEntityExceptionHandler : elle s'efface donc dès qu'une classe comme celle-ci existe, et elle ne couvre pas les exceptions métier. Dès qu'on a ses propres exceptions, la classe ci-dessus devient nécessaire.

7. Pagination et tri

Renvoyer toute une table n'est pas tenable dès que les données grossissent. Spring Data fournit Pageable (page demandée, taille, tri) et Page<T> (contenu et totaux). Côté contrôleur, il suffit de déclarer un paramètre Pageable : Spring le construit à partir de ?page=0&size=20&sort=dueDate,asc. @PageableDefault fixe les valeurs quand le client n'envoie rien. Le numéro de page commence à 0, ce qu'il vaut mieux préciser dans la documentation de l'API.

La taille maximale se plafonne avec une propriété, pour qu'un client ne puisse pas demander size=1000000 (la valeur par défaut est 2000) :

spring.data.web.pageable.max-page-size=100

Le tri demande une précaution. Le paramètre sort est une chaîne fournie par le client, et Spring Data la traduit en nom de propriété de l'entité. Si le client écrit ?sort=motDePasse,asc, ou n'importe quel champ qui n'existe pas, l'erreur remonte du repository sous la forme d'une PropertyReferenceException, que le handler générique transformerait en 500 alors que la faute vient du client. Pire, un tri sur un champ non indexé ou sur une relation peut coûter cher en base. D'où la liste blanche vue dans le contrôleur : SORTABLE_FIELDS contient les seuls champs autorisés, et tout autre nom lève InvalidSortException, traduite en 400 avec un message explicite. L'appel GET /api/v1/tasks?sort=motDePasse,asc renvoie bien un 400 avec le code INVALID_SORT.

La série sur Spring MVC présente l'autre manière de paginer, avec des @RequestParam explicites, et les contraintes de taille de page.

8. Tester et appeler l'API

Un test de contrôleur avec MockMvc

@WebMvcTest(TaskController.class) démarre uniquement la couche web : le contrôleur, la validation, les convertisseurs JSON et les @RestControllerAdvice. Le service est remplacé par un mock avec @MockitoBean, disponible à partir de Spring Framework 6.2, donc de Spring Boot 3.4. Il remplace @MockBean, déprécié avec forRemoval = true depuis Spring Boot 3.4.0 ; avec une version antérieure à la 3.4, @MockBean (org.springframework.boot.test.mock.mockito.MockBean) reste la seule option. Les tests vérifient trois choses : un 200 avec le bon corps, un 201 avec l'en-tête Location, et deux erreurs (404 et 400) pour confirmer que le format ProblemDetail est bien appliqué.

package com.example.tasks;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.example.tasks.dto.CreateTaskRequest;
import com.example.tasks.dto.TaskResponse;
import com.example.tasks.exception.TaskNotFoundException;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(TaskController.class)
class TaskControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private TaskService taskService;

    private static final TaskResponse SAMPLE = new TaskResponse(
            1L, "Relire le brouillon", null, TaskStatus.TODO, null,
            Instant.parse("2026-10-12T08:00:00Z"));

    @Test
    void getTask_returns200WithBody() throws Exception {
        when(taskService.findById(1L)).thenReturn(SAMPLE);

        mockMvc.perform(get("/api/v1/tasks/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title").value("Relire le brouillon"))
                .andExpect(jsonPath("$.status").value("TODO"));
    }

    @Test
    void createTask_returns201WithLocation() throws Exception {
        when(taskService.create(any(CreateTaskRequest.class))).thenReturn(SAMPLE);

        mockMvc.perform(post("/api/v1/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"Relire le brouillon\"}"))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "http://localhost/api/v1/tasks/1"))
                .andExpect(jsonPath("$.id").value(1));
    }

    @Test
    void getUnknownTask_returns404AsProblemDetail() throws Exception {
        when(taskService.findById(99L)).thenThrow(new TaskNotFoundException(99L));

        mockMvc.perform(get("/api/v1/tasks/99"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(404))
                .andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
    }

    @Test
    void createTaskWithBlankTitle_returns400WithFieldErrors() throws Exception {
        mockMvc.perform(post("/api/v1/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\" \"}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
                .andExpect(jsonPath("$.errors[0].field").value("title"));
    }
}

Ces tests n'assertent aucun texte accentué : MockHttpServletResponse décode par défaut en ISO-8859-1 les réponses sans charset déclaré, et une comparaison sur « Tâche introuvable » échouerait pour une mauvaise raison. Les champs code et status suffisent à vérifier le comportement.

Le service est mocké, donc ces tests ne disent rien de la logique métier ni de la base. Ils couvrent le contrat HTTP : routes, statuts, validation, format des erreurs. Les règles du service se testent à part, avec Mockito sur le repository, et le tout avec un @SpringBootTest ou @DataJpaTest selon le besoin. Un test @SpringBootTest avec MockMvc sur une base H2 en mémoire a servi à vérifier les réponses décrites dans cet article : doublon de titre en 409, échéance passée en 422, JSON illisible en 400, méthode non supportée en 405, modification d'une tâche terminée en 409 et suppression en deux temps (204 puis 404).

Appeler l'API avec curl

# Créer une tâche : -i affiche le statut et les en-têtes
curl -i -X POST http://localhost:8080/api/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Relire le brouillon","dueDate":"2030-01-15"}'
# HTTP/1.1 201
# Location: http://localhost:8080/api/v1/tasks/1

# Lire une page de tâches triées par échéance
curl "http://localhost:8080/api/v1/tasks?status=TODO&sort=dueDate,asc&size=10"

# Changer le statut
curl -X PATCH http://localhost:8080/api/v1/tasks/1/status \
  -H "Content-Type: application/json" \
  -d '{"status":"DONE"}'

# Supprimer (204, sans corps)
curl -i -X DELETE http://localhost:8080/api/v1/tasks/1

Appeler l'API depuis Java avec RestClient

Spring Framework 6.1 (Spring Boot 3.2) a introduit RestClient, un client synchrone avec une API fluide, que la documentation présente comme l'API moderne pour les appels HTTP synchrones. toEntity renvoie la réponse complète (statut, en-têtes, corps), ce qui permet de lire le Location.

package com.example.client;

import com.example.tasks.TaskStatus;
import com.example.tasks.dto.CreateTaskRequest;
import com.example.tasks.dto.TaskResponse;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

public class TaskApiClient {

    private final RestClient restClient = RestClient.create("http://localhost:8080/api/v1");

    public TaskResponse create(String title) {
        ResponseEntity<TaskResponse> response = restClient.post()
                .uri("/tasks")
                .contentType(MediaType.APPLICATION_JSON)
                .body(new CreateTaskRequest(title, null, null))
                .retrieve()
                .toEntity(TaskResponse.class);

        System.out.println("Statut : " + response.getStatusCode());
        System.out.println("Location : " + response.getHeaders().getLocation());
        return response.getBody();
    }

    public TaskResponse markAsDone(Long id) {
        return restClient.patch()
                .uri("/tasks/{id}/status", id)
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("status", TaskStatus.DONE))
                .retrieve()
                .body(TaskResponse.class);
    }

    public void deleteQuietly(Long id) {
        try {
            restClient.delete().uri("/tasks/{id}", id).retrieve().toBodilessEntity();
        } catch (RestClientResponseException ex) {
            System.err.println("Échec " + ex.getStatusCode() + " : " + ex.getResponseBodyAsString());
        }
    }
}

Par défaut, retrieve() lève une RestClientResponseException (ou une sous-classe, HttpClientErrorException pour les 4xx) quand la réponse est une erreur. Le corps de la réponse, qui contient le ProblemDetail, est accessible avec getResponseBodyAsString(). Avec un RestClient construit par RestClient.Builder injecté par Spring Boot, la configuration (convertisseurs JSON, instrumentation) est partagée avec le reste de l'application ; RestClient.create(...) est suffisant pour un script ou un exemple. Ce client a été exécuté contre l'API démarrée sur le port 8080 : création (201 avec Location), passage à DONE, puis deux suppressions, la seconde échouant en 404 avec le ProblemDetail affiché.

9. Et ensuite

Cette API n'est pas protégée : n'importe qui peut lire et modifier les tâches. L'étape suivante est l'authentification et les autorisations, qui produisent les 401 et 403 de la table de la première partie. L'article Spring MVC et Spring Boot, partie 3 : sécurité JWT et tests traite cette partie avec Spring Security, y compris l'interaction avec le handler générique signalée plus haut.

La documentation vient ensuite : springdoc-openapi génère une description OpenAPI à partir des contrôleurs et des DTO, et une interface Swagger UI pour essayer les appels. Les annotations @Operation et @ApiResponse précisent les statuts renvoyés, ce qui complète le travail fait avec ResponseEntity. La série sur l'application complète montre cette configuration.

Enfin, le versionnage. Le préfixe /api/v1 permet de publier une v2 à côté de la première quand un contrat doit changer de façon incompatible, sans casser les clients existants. Tant que les changements se limitent à ajouter des champs optionnels, la même version suffit.


Série « Construire une API REST avec Spring Boot » : partie 2 sur 2.

Au sommaire de la série :

  1. Du contrôleur au service
  2. Validation, erreurs et tests (cette partie)

Partie précédente : Du contrôleur au service

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.