Série « Spring MVC et Spring Boot » : partie 2 sur 3.
Au sommaire de la série :
- Contrôleurs, DTO et conteneur Spring
- Validation, erreurs et pagination (cette partie)
- Sécurité JWT et tests
Partie précédente : Contrôleurs, DTO et conteneur Spring · Partie suivante : Sécurité JWT et tests
La première partie a posé les bases d'une API REST avec Spring MVC : un contrôleur qui délègue à un service, des DTO sous forme de records, les annotations stéréotypes et Lombok. Cette deuxième partie s'intéresse à ce qui se passe quand les choses vont de travers : données invalides, ressource introuvable, conflit, paramètre hors limites. Elle couvre aussi la pagination, les interceptors et le test du contrôleur avec MockMvc.
Les exemples reprennent la fonctionnalité user (UserController, UserService, CreateUserRequest) et ajoutent une fonctionnalité product, avec les mêmes conventions de nommage. Ils ont été compilés et exécutés avec Spring Boot 3.5.
La validation : ne jamais faire confiance aux données entrantes
@Valid sur le paramètre du contrôleur déclenche automatiquement la validation Bean Validation. Si un champ ne passe pas, Spring renvoie un 400, mais le format par défaut de cette erreur est peu exploitable par les clients. On centralise donc la gestion des erreurs dans un @RestControllerAdvice :
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
public record FieldViolation(String field, String message) {
}
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleUserNotFound(UserNotFoundException ex) {
return problem(HttpStatus.NOT_FOUND, "Utilisateur introuvable", ex.getMessage(), "USER_NOT_FOUND");
}
@ExceptionHandler(EmailAlreadyUsedException.class)
public ProblemDetail handleEmailAlreadyUsed(EmailAlreadyUsedException ex) {
return problem(HttpStatus.CONFLICT, "Conflit", ex.getMessage(), "EMAIL_ALREADY_USED");
}
// Tri demandé sur un champ non autorisé (voir la pagination)
@ExceptionHandler(InvalidSortException.class)
public ProblemDetail handleInvalidSort(InvalidSortException ex) {
return problem(HttpStatus.BAD_REQUEST, "Tri invalide", ex.getMessage(), "INVALID_SORT");
}
// Course entre deux inscriptions : c'est la contrainte UNIQUE de la base qui tranche
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleIntegrity(DataIntegrityViolationException ex) {
log.warn("Violation de contrainte : {}", ex.getMostSpecificCause().getMessage());
return problem(HttpStatus.CONFLICT, "Conflit", "Conflit avec une donnée existante.", "DATA_CONFLICT");
}
// @Min / @Max sur un @RequestParam (validation de méthode native, Spring Framework 6.1+)
@Override
protected ResponseEntity<Object> handleHandlerMethodValidationException(
HandlerMethodValidationException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ProblemDetail body = problem(HttpStatus.BAD_REQUEST, "Paramètre invalide",
"Un paramètre de la requête est hors des limites autorisées.", "INVALID_PARAMETER");
return createResponseEntity(body, headers, status, request);
}
// @Valid sur un @RequestBody
@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);
}
// Filet de sécurité : le détail reste dans les logs, jamais dans la réponse
@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;
}
}
Les exceptions UserNotFoundException, EmailAlreadyUsedException et InvalidSortException sont de simples exceptions métier, sans logique propre. Une classe par cas, qui étend RuntimeException, suffit, et son nom dit ce qui s'est passé :
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long id) {
super("L'utilisateur " + id + " n'existe pas.");
}
}
Chaque exception a son handler. Oublier celui d'InvalidSortException, levée plus bas par la liste blanche de tri, est un piège : la requête ?sortBy=password finit alors dans le filet de sécurité et sort en 500, alors que c'est une erreur du client. Un test d'intégration qui appelle cette URL met l'oubli en évidence.
Quelques points de ce handler méritent une explication.
Le handler Exception.class est le filet de sécurité. Sans lui, une exception non gérée laisse Spring Boot produire sa page d'erreur par défaut qui, selon la configuration, peut inclure le stacktrace, des messages internes ou des fragments de requêtes SQL. Le handler attrape tout, journalise l'erreur complète côté serveur et renvoie au client un message générique.
La classe étend ResponseEntityExceptionHandler. Cette classe de base de Spring traite correctement les exceptions MVC standard (JSON illisible, méthode non supportée, paramètre manquant) et les renvoie au format ProblemDetail. Sans elle, le handler Exception.class les transformerait toutes en 500.
Les contraintes posées sur les paramètres. Depuis Spring Framework 6.1, Spring MVC valide nativement les contraintes @Min, @Max ou @NotBlank placées sur les paramètres d'une méthode de contrôleur, et lève une HandlerMethodValidationException en cas de violation. Le handler la surcharge pour la ramener au même format que les autres erreurs de validation ; sans surcharge, la classe de base répond déjà un 400 générique. La documentation de Spring recommande de ne pas poser @Validated sur la classe du contrôleur pour profiter de ce mécanisme : avec @Validated, la validation passe par un proxy AOP et lève à la place une ConstraintViolationException.
DataIntegrityViolationException. C'est la contrainte UNIQUE de la base qui tranche quand deux inscriptions arrivent en même temps (voir la section suivante).
Les handlers AuthenticationException et AccessDeniedException s'ajoutent avec la sécurité, dans la troisième partie. Le rôle de ResponseEntityExceptionHandler, des champs code et errors et du filet de sécurité est repris pas à pas dans la deuxième partie de la série sur l'API de gestion de tâches.
L'unicité d'un e-mail : 409 Conflict, pas 400
Un e-mail déjà utilisé n'est pas une erreur de format de la requête, c'est un conflit avec l'état actuel des données. Le code adapté est donc 409, et non 400. On le vérifie dans le service, et la contrainte UNIQUE en base reste le garde-fou final :
@Service
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public UserResponse create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new EmailAlreadyUsedException();
}
User user = new User(request.name(), request.email(),
passwordEncoder.encode(request.password()));
return UserResponse.from(userRepository.save(user));
}
// findAll, findById, update et delete suivent le même schéma
}
Une contrainte Bean Validation personnalisée de type @UniqueEmail est possible, mais elle produirait un 400 comme toutes les erreurs de validation, et elle reste sujette à une course : deux requêtes simultanées passent la vérification avant que l'une des deux n'insère. C'est pourquoi le handler traduit aussi la DataIntegrityViolationException en 409 : elle se produit quand la contrainte UNIQUE tranche.
Les contraintes personnalisées gardent leur intérêt pour les règles de format ou de cohérence interne d'une requête. Elles suivent une convention : chaque contrainte ne vérifie que sa propre responsabilité, et un isValid() renvoie true pour une valeur null, la nullité étant l'affaire de @NotNull ou @NotBlank. Sinon, le client recevrait deux messages d'erreur contradictoires pour le même champ.
Un format d'erreur standard : ProblemDetail
Les erreurs de ce GlobalExceptionHandler suivent la RFC 9457, qui remplace la RFC 7807 (le média est application/problem+json). Spring Framework 6 fournit ce format avec la classe ProblemDetail, utilisable dès Spring Boot 3.0. Chaque réponse porte les champs standard type, title, status, detail et instance, plus deux propriétés propres à l'API : code, un identifiant stable que les clients peuvent tester, et errors pour le détail des champs invalides. Pour un e-mail invalide, la réponse (en-tête Content-Type: application/problem+json) ressemble à ceci :
{
"type": "about:blank",
"title": "Requête invalide",
"status": 400,
"detail": "Un ou plusieurs champs sont invalides.",
"instance": "/api/v1/users",
"code": "VALIDATION_FAILED",
"errors": [
{ "field": "email", "message": "Format d'email invalide" }
]
}
Une Map maison reste possible, mais le choix du format est une décision de contrat avec les clients. L'important est qu'il soit unique pour toute l'API : avec ResponseEntityExceptionHandler en classe de base, les erreurs MVC standard sortent déjà en ProblemDetail, et mélanger deux formats obligerait les clients à en gérer deux. Les exceptions standard (méthode non supportée, JSON illisible) n'ont pas le champ code, ce que les clients doivent tolérer.
Les codes de statut à retenir sont ceux de la table de la première partie de la série REST. Cette série ajoute deux repères : 409 pour un conflit avec l'état des données (un e-mail déjà pris), et, avec la sécurité, 401 pour « pas authentifié » contre 403 pour « authentifié mais interdit ».
Pagination : ne jamais renvoyer toute la table
Une API qui renvoie SELECT * FROM products sans pagination pose problème dès que la table grossit. Avec 50 produits, ça passe. Avec 50 000, le serveur charge tout en mémoire, sérialise un JSON de plusieurs mégaoctets, et le client doit le parser en entier.
Spring Data fournit Pageable et Page, qui rendent la pagination simple. Deux précautions s'imposent : le tri et la taille de page viennent du client, donc il faut les contrôler.
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
// Champs autorisés pour le tri : jamais un nom d'attribut fourni tel quel par le client
private static final Set<String> SORTABLE_FIELDS = Set.of("createdAt", "name", "price");
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public ResponseEntity<PageResponse<ProductResponse>> findAll(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String direction) {
if (!SORTABLE_FIELDS.contains(sortBy)) {
throw new InvalidSortException(sortBy);
}
Sort sort = direction.equalsIgnoreCase("asc")
? Sort.by(sortBy).ascending()
: Sort.by(sortBy).descending();
return ResponseEntity.ok(PageResponse.from(
productService.findAll(PageRequest.of(page, size, sort))));
}
}
Ce code appelle quelques remarques.
La liste blanche pour sortBy. Transmettre le paramètre tel quel à Sort.by() laisse le client trier sur n'importe quel attribut de l'entité, y compris un champ sensible ou non indexé, et révèle la structure interne du modèle. Les noms invalides produisent aussi des erreurs (PropertyReferenceException) qui finissent en 500. Une liste blanche règle les deux problèmes.
Les contraintes @Min et @Max sur les paramètres. Elles bornent la page et la taille. Une taille de 1000 est refusée en 400 par la validation de méthode native (voir le handler plus haut) : le @Max(100) protège le serveur, car personne n'a besoin de milliers de résultats par page dans une interface web. Avec une version de Spring antérieure à la 6.1, ou avec @Validated sur la classe, ces annotations passent par un proxy AOP et lèvent une ConstraintViolationException, à mapper en 400 dans le handler.
Un DTO de pagination plutôt que Page directement. Sérialiser Page expose l'implémentation interne PageImpl (champs pageable, sort, etc.), dont la structure JSON n'est pas garantie d'une version de Spring Data à l'autre. Spring Data le signale d'ailleurs par un avertissement dans les journaux : « Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure! » (observé avec Spring Boot 3.5). Il vaut mieux figer le contrat de l'API :
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages());
}
}
Avec Spring Data 3.3 et plus (Spring Boot 3.3 et plus), PagedModel ou @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) fournissent un format stable sans écrire ce record. La réponse contient dans tous les cas les métadonnées de pagination, de quoi construire une navigation sans deviner.
Si l'on injecte directement un Pageable dans la méthode du contrôleur, la taille maximale se règle par spring.data.web.pageable.max-page-size (2000 par défaut), et le tri accepte n'importe quel attribut : la liste blanche reste nécessaire. Cette variante est détaillée dans la deuxième partie de la série sur l'API de gestion de tâches.
Interceptors : le middleware de Spring MVC
Les interceptors Spring MVC jouent un rôle comparable aux middlewares d'autres frameworks : ils interceptent les requêtes avant et après le traitement du contrôleur. L'usage le plus courant est la mesure des temps de réponse :
@Component
public class RequestTimingInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(RequestTimingInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
request.setAttribute("startTime", System.nanoTime());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
long startTime = (Long) request.getAttribute("startTime");
long durationMs = (System.nanoTime() - startTime) / 1_000_000;
log.info("{} {} -> {} ({} ms)",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
durationMs);
}
}
L'enregistrement de l'interceptor se fait dans une classe de configuration :
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final RequestTimingInterceptor timingInterceptor;
public WebConfig(RequestTimingInterceptor timingInterceptor) {
this.timingInterceptor = timingInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timingInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/health");
}
}
L'injection par constructeur s'applique aussi dans la configuration, avec la même discipline que dans les services. Le excludePathPatterns évite de polluer les journaux avec les sondes de santé (health checks) qu'appelle régulièrement le répartiteur de charge. L'interceptor ne journalise que la méthode, l'URI, le statut et la durée, jamais le corps de la requête : un corps de requête de connexion contient un mot de passe, qui n'a rien à faire dans un fichier de logs.
Tester le contrôleur avec MockMvc
Un test de contrôleur n'a pas besoin de démarrer toute l'application ni la base de données. @WebMvcTest ne charge que la couche web (contrôleurs, @RestControllerAdvice, convertisseurs JSON, filtres, interceptors), et @MockitoBean remplace le service par un double. @MockitoBean existe depuis Spring Framework 6.2, donc Spring Boot 3.4. Il remplace @MockBean, déprécié avec forRemoval = true depuis Spring Boot 3.4.0 ; avec une version antérieure, @MockBean reste la seule option.
Cela suffit pour vérifier ce qui appartient au contrôleur : les codes HTTP, l'en-tête Location, la validation et le format des erreurs.
@WebMvcTest(UserController.class)
class UserControllerTest {
private static final String VALID_BODY = """
{"name":"Awa","email":"awa@example.com","password":"correct-horse-battery"}
""";
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
void createUser_returns201WithLocation() throws Exception {
when(userService.create(any(CreateUserRequest.class))).thenReturn(
new UserResponse(42L, "Awa", "awa@example.com", LocalDateTime.of(2026, 1, 15, 10, 0)));
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_BODY))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "http://localhost/api/v1/users/42"))
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.password").doesNotExist());
}
@Test
void createUser_withInvalidEmail_returns400() throws Exception {
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Awa","email":"pas-un-email","password":"correct-horse-battery"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.errors[0].field").value("email"));
}
@Test
void createUser_withExistingEmail_returns409() throws Exception {
when(userService.create(any(CreateUserRequest.class)))
.thenThrow(new EmailAlreadyUsedException());
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_BODY))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_USED"));
}
}
Le choix de "pas-un-email" pour le cas invalide est volontaire : les autres champs sont valides, donc une seule contrainte échoue et le message attendu est déterministe. Avec un nom vide, @NotBlank et @Size échoueraient ensemble, et le message retenu dépendrait de l'ordre dans lequel Hibernate Validator évalue les contraintes.
Le test de la pagination vérifie les deux protections du contrôleur, sans base de données :
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private ProductService productService;
@Test
void findAll_returnsPageMetadata() throws Exception {
when(productService.findAll(any(Pageable.class))).thenReturn(
new PageImpl<>(List.of(new ProductResponse(1L, "Clavier", new BigDecimal("49.90")))));
mockMvc.perform(get("/api/v1/products?size=10&sortBy=price&direction=asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].name").value("Clavier"))
.andExpect(jsonPath("$.totalElements").value(1));
}
@Test
void findAll_withTooLargePageSize_returns400() throws Exception {
mockMvc.perform(get("/api/v1/products?size=1000"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_PARAMETER"));
}
@Test
void findAll_withUnknownSortField_returns400() throws Exception {
mockMvc.perform(get("/api/v1/products?sortBy=password"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_SORT"));
}
}
Les règles de sécurité se testent à part : la troisième partie le montre avec spring-security-test.
À retenir de cette deuxième partie
@RestControllerAdviceétendantResponseEntityExceptionHandlercentralise les erreurs, avec un formatProblemDetailunique.- Chaque exception métier a son handler, et le filet de sécurité
Exception.classne renvoie jamais de détail interne. - Un conflit d'unicité se vérifie dans le service (409) et se garantit par une contrainte
UNIQUE. - La pagination exige une taille maximale, une liste blanche de tris et un DTO de page stable.
@WebMvcTestavec@MockitoBeanteste le contrat HTTP sans démarrer toute l'application.
La troisième partie protège cette API avec Spring Security et des jetons JWT.
Série « Spring MVC et Spring Boot » : partie 2 sur 3.
Au sommaire de la série :
- Contrôleurs, DTO et conteneur Spring
- Validation, erreurs et pagination (cette partie)
- Sécurité JWT et tests
Partie précédente : Contrôleurs, DTO et conteneur Spring · Partie suivante : Sécurité JWT et tests