Contactez-nous

Validation dans les contrôleurs (`@Valid`, `BindingResult`)

Apprenez à déclencher la validation Bean Validation dans vos contrôleurs Spring MVC/REST en utilisant @Valid et à gérer les erreurs avec BindingResult.

Intégration de la validation dans le flux de la requête

Après avoir défini les contraintes de validation sur nos objets (DTOs, formulaires) à l'aide des annotations Bean Validation (@NotNull, @Size, etc.), l'étape suivante consiste à déclencher cette validation lorsque les données arrivent dans nos contrôleurs Spring. L'objectif est d'intercepter les données invalides le plus tôt possible, avant qu'elles n'atteignent la logique métier.

Spring MVC offre une intégration transparente avec Bean Validation grâce à l'annotation @Valid et à l'interface BindingResult. Ces outils permettent d'activer la validation et de gérer les erreurs de manière structurée directement dans les méthodes de nos contrôleurs.

Déclencher la validation avec `@Valid`

L'annotation javax.validation.Valid (ou org.springframework.validation.annotation.Validated qui est une alternative spécifique à Spring offrant des fonctionnalités de groupement) est la clé pour activer la validation. Vous devez la placer juste avant le paramètre de méthode de handler qui représente l'objet que vous souhaitez valider.

Cet objet est généralement celui qui a été lié aux données de la requête, soit via @ModelAttribute (pour les formulaires web traditionnels) soit via @RequestBody (pour les API REST recevant du JSON/XML).

Lorsque Spring MVC rencontre l'annotation @Valid sur un tel paramètre, il invoque automatiquement l'implémentation de Bean Validation configurée (généralement Hibernate Validator, inclus par défaut via spring-boot-starter-validation ou spring-boot-starter-web) pour vérifier si l'objet respecte toutes les contraintes définies dans sa classe.

// Exemple dans un contrôleur REST
@RestController
@RequestMapping("/api/users")
public class UserApiController {
    @PostMapping
    public ResponseEntity createUser(@Valid @RequestBody UserCreateDto userDto) {
        // Si on arrive ici sans erreur, userDto est valide (selon les contraintes de UserCreateDto)
        User newUser = userService.create(userDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(newUser);
    }
}

// Exemple dans un contrôleur Web traditionnel
@Controller
public class RegistrationController {
    @PostMapping("/register")
    public String processRegistration(@Valid @ModelAttribute("userForm") UserForm userForm /* ... */) {
         // Si on arrive ici sans erreur, userForm est valide
         userService.register(userForm);
         return "redirect:/registrationSuccess";
    }
}

Mais que se passe-t-il si la validation échoue ? Par défaut, si vous utilisez uniquement @Valid, Spring lèvera une exception (typiquement MethodArgumentNotValidException pour @RequestBody ou BindException pour @ModelAttribute), ce qui interrompt le flux normal et déclenche généralement le mécanisme de gestion d'erreurs global (par exemple, affichage de la page d'erreur Whitelabel ou réponse d'erreur 500).

Capturer les erreurs avec `BindingResult`

Pour gérer les erreurs de validation de manière plus contrôlée sans lever d'exception, nous devons utiliser l'interface org.springframework.validation.BindingResult. C'est un conteneur qui recueille toutes les erreurs de validation et de liaison de données survenues pour l'objet annoté @Valid.

Point crucial : Pour que cela fonctionne, le paramètre BindingResult doit être déclaré immédiatement après le paramètre annoté avec @Valid dans la signature de la méthode. Si vous placez un autre paramètre entre les deux, Spring ne pourra pas associer le BindingResult à l'objet validé et lèvera une exception en cas d'erreur.

// CORRECT: BindingResult suit immédiatement @Valid @RequestBody
public ResponseEntity createUser(@Valid @RequestBody UserCreateDto userDto, BindingResult bindingResult) { ... }

// CORRECT: BindingResult suit immédiatement @Valid @ModelAttribute
public String processRegistration(@Valid @ModelAttribute("userForm") UserForm userForm, BindingResult bindingResult, Model model) { ... }

// INCORRECT: Un autre paramètre (Model) est entre @Valid et BindingResult
// public String processRegistration(@Valid @ModelAttribute("userForm") UserForm userForm, Model model, BindingResult bindingResult) { ... } 
// => Une BindException sera levée si userForm est invalide.

Une fois que vous avez accès à l'objet BindingResult, vous pouvez vérifier s'il y a eu des erreurs en utilisant la méthode hasErrors().

Traitement des erreurs de validation

Si bindingResult.hasErrors() retourne true, vous savez que la validation a échoué. Vous pouvez alors choisir la stratégie appropriée :

  • Pour les applications web traditionnelles (avec vues) : L'approche la plus courante est de retourner le nom logique de la vue du formulaire. Comme l'objet @ModelAttribute (userForm dans l'exemple) et le BindingResult sont automatiquement ajoutés au modèle par Spring, la vue (par exemple, Thymeleaf) peut accéder aux erreurs via BindingResult pour les afficher à côté des champs correspondants et peut utiliser l'objet @ModelAttribute pour pré-remplir le formulaire avec les données précédemment saisies par l'utilisateur.
  • Pour les API REST : L'approche standard est de retourner une réponse HTTP avec un statut d'erreur client, typiquement 400 Bad Request. Le corps de la réponse devrait idéalement contenir des détails sur les erreurs de validation (quels champs sont en erreur et pourquoi) pour aider le client de l'API à corriger la requête. Vous pouvez extraire ces informations de BindingResult.

L'objet BindingResult fournit plusieurs méthodes pour accéder aux détails des erreurs :

  • hasErrors(): Retourne true s'il y a au moins une erreur.
  • getErrorCount(): Retourne le nombre total d'erreurs.
  • getAllErrors(): Retourne une liste de ObjectError (erreurs globales sur l'objet).
  • hasFieldErrors(String field): Vérifie s'il y a des erreurs pour un champ spécifique.
  • getFieldErrors(): Retourne une liste de FieldError (erreurs spécifiques à un champ).
  • getFieldError(String field): Retourne la première FieldError pour un champ donné.

Chaque FieldError contient des informations utiles comme le nom du champ, la valeur rejetée, et le message d'erreur (souvent résolu à partir des fichiers de messages i18n).

Exemple Complet (Web Traditionnel)

@Controller
public class RegistrationController {

    @Autowired
    private UserService userService;

    @GetMapping("/register")
    public String showRegistrationForm(Model model) {
        // Ajoute un objet UserForm vide pour la liaison initiale
        if (!model.containsAttribute("userForm")) {
            model.addAttribute("userForm", new UserForm());
        }
        return "registrationForm";
    }

    @PostMapping("/register")
    public String processRegistration(@Valid @ModelAttribute("userForm") UserForm userForm, 
                                      BindingResult bindingResult, 
                                      Model model /* autres paramètres si besoin */) {
        
        // Vérifie les erreurs de validation
        if (bindingResult.hasErrors()) {
            // Si erreurs, retourne à la vue du formulaire.
            // userForm et bindingResult sont automatiquement ajoutés au modèle,
            // permettant à Thymeleaf d'afficher les erreurs et de pré-remplir les champs.
             System.out.println("Erreurs de validation trouvées: " + bindingResult.getAllErrors());
            return "registrationForm"; 
        }
        
        // Si aucune erreur, procéder à l'enregistrement
        userService.register(userForm);
        
        // Rediriger après succès (Pattern PRG)
        return "redirect:/registrationSuccess"; 
    }
    
    @GetMapping("/registrationSuccess")
    public String showSuccessPage() {
        return "registrationSuccessView";
    }
}

// DTO/Formulaire avec annotations de validation
class UserForm {
    @NotBlank(message = "Le nom d'utilisateur ne peut pas être vide")
    @Size(min = 3, max = 20, message = "Le nom d'utilisateur doit contenir entre {min} et {max} caractères")
    private String username;

    @NotBlank(message = "L'email est obligatoire")
    @Email(message = "Format d'email invalide")
    private String email;
    
    // Getters et Setters...
}

Dans la vue Thymeleaf registrationForm.html, vous utiliseriez th:errors pour afficher les messages d'erreur associés aux champs :

Error message
Error message

Exemple Complet (API REST)

import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/products")
public class ProductApiController {

    @PostMapping
    public ResponseEntity createProduct(@Valid @RequestBody ProductDto productDto, BindingResult bindingResult) {
        
        if (bindingResult.hasErrors()) {
            // Construire une réponse d'erreur structurée
            Map errors = bindingResult.getFieldErrors().stream()
                    .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
            
             // Alternative: Collecter toutes les erreurs
            // List errorMessages = bindingResult.getAllErrors().stream()
            //        .map(ObjectError::getDefaultMessage)
            //        .collect(Collectors.toList());

            System.out.println("Erreurs de validation API: " + errors);
            return ResponseEntity.badRequest().body(errors); // Retourne 400 Bad Request avec les erreurs
        }

        // Si valide, créer le produit...
        // Product newProduct = productService.create(productDto);
        Product newProduct = new Product(1L, productDto.getName(), productDto.getPrice()); // Simulation

        return ResponseEntity.status(HttpStatus.CREATED).body(newProduct);
    }
}

// DTO avec validation
class ProductDto {
    @NotBlank
    private String name;
    @NotNull
    @PositiveOrZero
    private Double price;
    // Getters et Setters...
}
class Product { /* ... */ } // Classe entité ou réponse

Dans ce cas, si la validation échoue, le client de l'API recevra une réponse 400 avec un corps JSON comme {"name": "ne doit pas être vide", "price": "doit être un nombre positif ou zéro"} (en supposant les messages par défaut).

Conclusion : Une validation propre et intégrée

L'utilisation combinée de @Valid et BindingResult dans les contrôleurs Spring offre un moyen standardisé, déclaratif et non intrusif d'intégrer la validation des données d'entrée dans le flux de traitement des requêtes. Cela permet de séparer clairement la logique de validation des règles métier, d'améliorer la robustesse de l'application et de fournir un retour d'information précis à l'utilisateur ou au client API en cas d'erreur, tout en évitant la propagation d'exceptions pour les erreurs de validation attendues.