Skip to content

3. Le modèle d'une action

Une action a besoin des informations envoyées par le navigateur : les paramètres de l'URL ([?nom=martin&age=42]), les champs d'un formulaire, le corps JSON d'une requête, parfois un entête HTTP. Le cours d'origine appelle modèle de l'action l'ensemble de ces informations. Ce chapitre montre comment [Spring MVC] les fournit à l'action, les convertit dans le bon type et les valide. Ce travail s'appelle la liaison de données ([data binding]).

Attention au vocabulaire : dans [Spring MVC], le mot « modèle » ([Model]) désigne surtout les données qu'une action transmet à sa vue (chapitre 4, « La vue et son modèle »). Le modèle d'une action, au sens de ce chapitre, est ce qu'elle reçoit.

Les exemples 07 à 12 sont, comme ceux du chapitre précédent, des modules [Maven] du dossier [exemples]. On lance l'un d'eux en ouvrant un terminal sur son dossier et en tapant [../mvnw spring-boot:run] ([..\mvnw spring-boot:run] sous [Windows]) ; [Ctrl-C] arrête le serveur. Chaque exemple a un fichier [application.properties] de ce genre :

1
2
3
4
# le port d'écoute du serveur (8080 par défaut)
server.port=5000
# les réponses d'erreur de Spring Boot ne contiennent pas la pile d'appels
spring.web.error.include-stacktrace=never
  • ligne 2 : le port d'écoute du serveur ;
  • ligne 4 : quand une requête échoue, [Spring Boot] répond par un document JSON d'erreur (nous en verrons beaucoup dans ce chapitre). Cette propriété interdit d'y placer la pile d'appels ([stack trace]) de l'exception, qui est longue, et qu'on ne doit jamais montrer à un utilisateur. Elle est nécessaire parce que la dépendance [spring-boot-devtools] (les outils de développement, déclarés dans le [pom.xml] parent) change, pendant le développement, plusieurs réglages par défaut : elle ajoute notamment à ce JSON le message de l'exception (propriété [spring.web.error.include-message]) et la pile d'appels ([spring.web.error.include-stacktrace]). Nous gardons le message, utile pour comprendre ce qui s'est passé ; en production, sans les outils de développement, il disparaît lui aussi.

3.1. Exemple 07 : les paramètres de l'URL

Le fichier [controllers/ParametresController.java] :

package exemple07.controllers;

import java.util.Map;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/parametres")
public class ParametresController {

    // /parametres/action01?nom=martin : le paramètre [nom] de l'URL -> le paramètre [nom] de l'action
    // required = false : le paramètre peut manquer, il vaut alors null
    @GetMapping("/action01")
    public String action01(@RequestParam(required = false) String nom) {
        return "nom = [%s], de type %s".formatted(nom, nom == null ? "null" : nom.getClass().getSimpleName());
    }

    // /parametres/action02?age=42 : la valeur est CONVERTIE dans le type du paramètre ;
    // sans required = false, le paramètre est obligatoire
    @GetMapping("/action02")
    public String action02(@RequestParam int age) {
        return "age = [%d], de type int".formatted(age);
    }

    // /parametres/action03?age=42 : Integer (et non int) accepte la valeur null
    @GetMapping("/action03")
    public String action03(@RequestParam(required = false) Integer age) {
        return "age = [%s]".formatted(age);
    }

    // /parametres/action04?page=2 : une valeur par défaut si le paramètre manque
    @GetMapping("/action04")
    public String action04(@RequestParam(defaultValue = "1") int page) {
        return "page = [%d]".formatted(page);
    }

    // /parametres/action05?q=spring : le nom du paramètre de l'URL, quand il diffère
    @GetMapping("/action05")
    public String action05(@RequestParam(name = "q", required = false) String recherche) {
        return "recherche = [%s]".formatted(recherche);
    }

    // /parametres/action06?nom=martin&age=42 : tous les paramètres d'un coup
    @GetMapping("/action06")
    public Map<String, String> action06(@RequestParam Map<String, String> parametres) {
        return parametres;
    }
}

Commentons ce code :

  • lignes 16-17 : l'annotation [@RequestParam] associe un paramètre de l'action à un paramètre de l'URL (la partie qui suit le [?], appelée chaîne de requête ou [query string]). Sans autre précision, le paramètre de l'URL porte le même nom que le paramètre [Java] : [nom]. Ce nom n'est connu à l'exécution que parce que le projet parent de [Spring Boot] demande au compilateur de le conserver dans le code compilé (option [-parameters]). Une annotation peut recevoir des arguments nommés, appelés attributs : [required = false] indique que le paramètre peut manquer ; il vaut alors [null]. Ligne 18 : la méthode [formatted] remplace chaque [%s] de la chaîne par l'argument suivant ; [nom.getClass().getSimpleName()] est le nom court du type de la valeur ;
  • lignes 23-24 : le paramètre est déclaré de type [int] : [Spring MVC] convertit la chaîne reçue (["42"]) en nombre. Sans l'attribut [required = false], [@RequestParam] rend le paramètre obligatoire : c'est la valeur par défaut de [required]. Un paramètre absent, ou dont la conversion échoue, provoque une erreur 400, et l'action n'est pas exécutée ;
  • lignes 29-30 : un [int] ne peut pas valoir [null]. Pour un nombre facultatif, on déclare le paramètre avec le type [Integer], la classe [Java] qui « enveloppe » un [int] ([wrapper]) : un objet [Integer] peut valoir [null], ce qui permet de distinguer « pas d'âge » de « âge 0 » ;
  • lignes 35-36 : l'attribut [defaultValue] fournit la valeur à utiliser quand le paramètre manque (il rend aussi le paramètre facultatif). C'est une chaîne, convertie comme le serait la valeur reçue ;
  • lignes 41-42 : l'attribut [name] donne le nom du paramètre de l'URL quand il diffère de celui du paramètre [Java] : le paramètre [recherche] de l'action reçoit le paramètre [q] de l'URL ;
  • lignes 47-48 : un paramètre de type [Map<String, String>] annoté [@RequestParam] reçoit tous les paramètres de l'URL, sans conversion : un dictionnaire (nom du paramètre, valeur). L'action le renvoie tel quel, et [Spring MVC] le sérialise en JSON.

Les résultats :

curl -i "http://localhost:5000/parametres/action01?nom=martin"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 30

nom = [martin], de type String
curl -i "http://localhost:5000/parametres/action01"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 26

nom = [null], de type null

Le serveur [Tomcat] n'écrit pas le texte [OK] après le code 200 de la première ligne : ce texte est facultatif en HTTP/1.1, seul le code compte. Les guillemets autour de l'URL sont nécessaires quand elle contient le caractère [&], qui a un sens particulier pour la console ; nous les mettons partout. Un paramètre de type [int] :

curl -i "http://localhost:5000/parametres/action02?age=42"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 23

age = [42], de type int
curl -i "http://localhost:5000/parametres/action02?age=abc"
1
2
3
4
HTTP/1.1 400
Content-Type: application/json

{"timestamp":"2026-09-25T16:46:05.177Z","status":400,"error":"Bad Request","message":"Method parameter 'age': Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: \"abc\"","path":"/parametres/action02"}
curl -i "http://localhost:5000/parametres/action02"
1
2
3
4
HTTP/1.1 400
Content-Type: application/json

{"timestamp":"2026-09-25T16:46:05.205Z","status":400,"error":"Bad Request","message":"Required parameter 'age' is not present.","path":"/parametres/action02"}

C'est une différence importante avec [ASP.NET Core MVC], où une conversion impossible n'empêchait pas l'exécution de l'action. Avec [@RequestParam], [Spring MVC] lève une exception avant d'appeler l'action : [MethodArgumentTypeMismatchException] pour une conversion impossible, [MissingServletRequestParameterException] pour un paramètre obligatoire absent (la console du serveur affiche le nom de ces exceptions). Un composant de [Spring MVC] les transforme en réponse d'erreur 400 ([Bad Request], requête incorrecte), et [Spring Boot] produit le corps de cette réponse : c'est le JSON d'erreur déjà rencontré au chapitre précédent. Ses champs :

  • [timestamp] : la date et l'heure de l'erreur (en temps universel) ;
  • [status], [error] : le code HTTP et son libellé ;
  • [message] : le message de l'exception, présent ici parce que les outils de développement l'ont demandé ;
  • [path] : le chemin de la requête.

Un navigateur, qui demande une page HTML, reçoit à la place une page d'erreur minimale, la « Whitelabel Error Page ». Nous verrons à l'exemple 12 comment traiter soi-même ces exceptions. Avec le type [Integer] :

curl -i "http://localhost:5000/parametres/action03?age=42"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 10

age = [42]
curl -i "http://localhost:5000/parametres/action03"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 12

age = [null]
curl -i "http://localhost:5000/parametres/action03?age=abc"
1
2
3
4
HTTP/1.1 400
Content-Type: application/json

{"timestamp":"2026-09-25T16:46:05.250Z","status":400,"error":"Bad Request","message":"Method parameter 'age': Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'; For input string: \"abc\"","path":"/parametres/action03"}

Un paramètre absent vaut [null] ; un paramètre invalide provoque toujours une erreur 400. La valeur par défaut :

curl -i "http://localhost:5000/parametres/action04"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 10

page = [1]
curl -i "http://localhost:5000/parametres/action04?page=2"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 10

page = [2]
curl -i "http://localhost:5000/parametres/action04?page="
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 10

page = [1]

Un paramètre présent mais vide ([?page=]) est traité comme un paramètre absent : il prend la valeur par défaut. Les deux dernières actions :

curl -i "http://localhost:5000/parametres/action05?q=spring"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 20

recherche = [spring]
curl -i "http://localhost:5000/parametres/action06?nom=martin&age=42"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 27

{"nom":"martin","age":"42"}

L'âge est ici une chaîne (["42"]) : sans type déclaré, pas de conversion. Enfin, le nom d'un paramètre est sensible à la casse :

curl -i "http://localhost:5000/parametres/action01?NOM=martin"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 26

nom = [null], de type null

Le paramètre [NOM] n'est pas le paramètre [nom], qui est donc absent. C'est une autre différence avec [ASP.NET Core MVC], qui ignore la casse. On retiendra :

  • [@RequestParam] lie un paramètre de l'action à un paramètre de l'URL de même nom, majuscules comprises (attribut [name] si les noms diffèrent) ;
  • le paramètre est obligatoire par défaut ; [required = false] le rend facultatif, [defaultValue] lui donne une valeur de remplacement ;
  • sa valeur est convertie dans le type déclaré, et une conversion impossible donne une erreur 400 sans exécuter l'action. Un type objet ([Integer] et non [int]) permet de recevoir [null].

Les caractères accentués et les espaces sont codés dans une URL ([%C3%A9] pour [é]...) : [Spring MVC] les décode avant la liaison. Le navigateur affiche l'URL codée, et le JSON reçu :

Image

3.2. Exemple 08 : un modèle d'action décrit par une classe

Quand une action a plusieurs paramètres, on les décrit par une classe. Chaque champ de la classe porte des contraintes de validation, sous forme d'annotations. Elles sont définies par [Bean Validation], la norme [Java] de validation (package [jakarta.validation]), dont l'implémentation la plus répandue est [Hibernate Validator]. Elles ne font pas partie de [Spring MVC] : il faut ajouter le starter de validation au [pom.xml] de l'exemple :

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>cours.springmvc</groupId>
    <artifactId>exemples</artifactId>
    <version>1.0</version>
  </parent>
  <artifactId>exemple08</artifactId>

  <dependencies>
    <!-- la validation (Bean Validation, Hibernate Validator) -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
  </dependencies>
</project>
  • lignes 6-10 : le projet parent, le [pom.xml] du dossier [exemples] ;
  • lignes 13-19 : le starter [spring-boot-starter-validation] apporte [Hibernate Validator] et le configure. Rappelons qu'un starter est une dépendance qui en regroupe d'autres, avec leurs versions et leur configuration automatique. Les exemples 09 à 12 ont la même dépendance.

Le fichier [models/PersonneModele.java] :

package exemple08.models;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

// le modèle de l'action : une classe dont chaque champ porte ses contraintes
public class PersonneModele {

    @NotBlank(message = "le nom est obligatoire")
    @Size(min = 2, max = 20, message = "le nom doit avoir de 2 à 20 caractères")
    private String nom;

    @NotNull(message = "l'âge est obligatoire")
    @Min(value = 0, message = "l'âge doit être compris entre 0 et 150")
    @Max(value = 150, message = "l'âge doit être compris entre 0 et 150")
    private Integer age;

    @NotNull(message = "le titre est obligatoire")
    @Pattern(regexp = "Mr|Mme|Melle", message = "titre inconnu")
    private String titre;

    // facultatif : pas de @NotNull
    @Email(message = "adresse électronique invalide")
    private String email;

    // les accesseurs (getters et setters) : la liaison de modèle remplit
    // chaque champ par son setter ; la sérialisation JSON le lit par son getter
    public String getNom() { return nom; }
    public void setNom(String nom) { this.nom = nom; }

    public Integer getAge() { return age; }
    public void setAge(Integer age) { this.age = age; }

    public String getTitre() { return titre; }
    public void setTitre(String titre) { this.titre = titre; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

Commentons ce code :

  • lignes 3-9 : les annotations de validation, toutes dans le package [jakarta.validation.constraints] ;
  • lignes 14-16 : le nom est obligatoire et non vide ([@NotBlank] refuse [null], la chaîne vide et une chaîne faite d'espaces) et doit avoir de 2 à 20 caractères ([@Size]). Chaque annotation accepte un message d'erreur personnalisé (attribut [message]) ;
  • lignes 18-21 : l'âge est converti en entier, obligatoire ([@NotNull]), compris entre 0 et 150 ([@Min], [@Max]). Il est déclaré [Integer] et non [int], pour que « pas d'âge » ([null]) soit détecté par [@NotNull] au lieu de devenir 0 ;
  • lignes 23-25 : le titre doit vérifier l'expression régulière [Mr|Mme|Melle] ([@Pattern]) : il doit être l'une de ces trois valeurs ;
  • lignes 28-29 : l'adresse électronique est facultative (pas de [@NotNull]) ; si elle est présente, elle doit être valide ([@Email]). Toutes les contraintes autres que [@NotNull] et [@NotBlank] acceptent la valeur [null] : c'est ce qui rend un champ facultatif ;
  • lignes 33-43 : les accesseurs. Par convention, la valeur du champ [nom] se lit par la méthode [getNom] ([getter]) et s'écrit par la méthode [setNom] ([setter]). Une classe qui a un constructeur sans paramètre (ici, celui que [Java] fournit implicitement) et des accesseurs qui suivent cette convention s'appelle un JavaBean. C'est la forme qu'attend la liaison de données : [Spring MVC] crée l'objet avec son constructeur sans paramètre, puis, pour chaque paramètre de la requête, appelle le setter de même nom : [setNom("martin")] pour [?nom=martin], après avoir converti la valeur dans le type du setter. Le convertisseur JSON, lui, lit les champs par leurs getters.

Le fichier [controllers/PersonnesController.java] :

package exemple08.controllers;

import java.util.LinkedHashMap;
import java.util.Map;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import exemple08.models.PersonneModele;
import jakarta.validation.Valid;

@RestController
@RequestMapping("/personnes")
public class PersonnesController {

    // /personnes/action01?nom=martin&age=42&titre=Mr
    // @Valid : le modèle est validé AVANT l'exécution de l'action ; s'il est
    // invalide, la requête est refusée AUTOMATIQUEMENT (erreur 400)
    @GetMapping("/action01")
    public Map<String, Object> action01(@Valid PersonneModele personne) {
        Map<String, Object> reponse = new LinkedHashMap<>();
        reponse.put("personne", personne);
        reponse.put("typeAge", personne.getAge().getClass().getSimpleName());
        return reponse;
    }
}
  • ligne 21 : l'action reçoit un [PersonneModele]. Le paramètre n'a pas d'annotation [@RequestParam] : pour un paramètre dont le type n'est pas un type « simple » (chaîne, nombre, date...), [Spring MVC] suppose l'annotation [@ModelAttribute], qu'on aurait pu écrire. Le paramètre est alors un objet de commande ([command object]), créé et rempli par la liaison de données comme on vient de le décrire. Les paramètres de la requête qui ne correspondent à aucun setter sont ignorés ;
  • ligne 21 : l'annotation [@Valid] (de [jakarta.validation]) demande de valider l'objet après l'avoir rempli, en vérifiant les contraintes de ses champs. Si la liaison ou la validation échoue, [Spring MVC] lève l'exception [MethodArgumentNotValidException] : l'action n'est pas exécutée et la réponse est une erreur 400. C'est l'équivalent de l'attribut [[ApiController]] d'[ASP.NET Core MVC] ;
  • lignes 22-25 : l'action rend la personne reçue et le type de son âge. [LinkedHashMap] est un dictionnaire qui garde l'ordre d'insertion de ses clés : le JSON produit les aura dans cet ordre.

Le fichier [application.properties] de l'exemple a deux lignes de plus :

1
2
3
4
5
6
# le port d'écoute du serveur (8080 par défaut)
server.port=5000
# les réponses d'erreur de Spring Boot ne contiennent pas la pile d'appels
spring.web.error.include-stacktrace=never
# les réponses d'erreur détaillent les erreurs de validation
spring.web.error.include-binding-errors=always
  • ligne 6 : quand la réponse d'erreur provient d'une validation, elle contient la liste des erreurs (champ [errors]). Les outils de développement l'activent déjà ; cette ligne la garde en production. Sans elle, et sans les outils de développement, la réponse d'erreur se réduit à [{"timestamp":"...","status":400,"error":"Bad Request","path":"/personnes/action01"}] : le client apprend que sa requête est incorrecte, mais pas pourquoi.

Une requête valide, avec un paramètre [pirate] qui ne correspond à aucun champ :

curl -i "http://localhost:5000/personnes/action01?nom=martin&age=42&titre=Mr&pirate=1"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 84

{"personne":{"age":42,"email":null,"nom":"martin","titre":"Mr"},"typeAge":"Integer"}

L'âge est bien un nombre (un [Integer]), et le paramètre [pirate] a été ignoré. Les propriétés de la personne sont rangées par ordre alphabétique : c'est le choix par défaut de [Jackson] 3, la bibliothèque JSON utilisée par [Spring MVC]. Une requête invalide (la réponse JSON a été mise en forme pour la lisibilité : une propriété par ligne) :

curl -i "http://localhost:5000/personnes/action01?nom=m&age=abc&titre=Dr&email=xx"
HTTP/1.1 400
Content-Type: application/json

{
  "timestamp": "2026-09-25T16:46:41.520Z",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed for object='personneModele'. Error count: 4",
  "errors": [
    {
      "arguments": [{"arguments":null,"code":"age","codes":["personneModele.age","age"],"defaultMessage":"age"}],
      "bindingFailure": true,
      "code": "typeMismatch",
      "codes": ["typeMismatch.personneModele.age","typeMismatch.age","typeMismatch.java.lang.Integer","typeMismatch"],
      "defaultMessage": "Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'age'; For input string: \"abc\"",
      "field": "age",
      "objectName": "personneModele",
      "rejectedValue": "abc"
    },
    {
      "objectName": "personneModele",
      "field": "email",
      "rejectedValue": "xx",
      "codes": ["Email.personneModele.email","Email.email","Email.java.lang.String","Email"],
      "arguments": [{"arguments":null,"code":"email","codes":["personneModele.email","email"],"defaultMessage":"email"},[],{"arguments":null,"codes":[".*"],"defaultMessage":".*"}],
      "bindingFailure": false,
      "code": "Email",
      "defaultMessage": "adresse électronique invalide"
    },
    {
      "objectName": "personneModele",
      "field": "titre",
      "rejectedValue": "Dr",
      "codes": ["Pattern.personneModele.titre","Pattern.titre","Pattern.java.lang.String","Pattern"],
      "arguments": [{"arguments":null,"code":"titre","codes":["personneModele.titre","titre"],"defaultMessage":"titre"},[],{"arguments":null,"codes":["Mr|Mme|Melle"],"defaultMessage":"Mr|Mme|Melle"}],
      "bindingFailure": false,
      "code": "Pattern",
      "defaultMessage": "titre inconnu"
    },
    {
      "objectName": "personneModele",
      "field": "nom",
      "rejectedValue": "m",
      "codes": ["Size.personneModele.nom","Size.nom","Size.java.lang.String","Size"],
      "arguments": [{"arguments":null,"code":"nom","codes":["personneModele.nom","nom"],"defaultMessage":"nom"},20,2],
      "bindingFailure": false,
      "code": "Size",
      "defaultMessage": "le nom doit avoir de 2 à 20 caractères"
    }
  ],
  "path": "/personnes/action01"
}

L'action n'a pas été exécutée. Le champ [errors] liste les erreurs, une par champ refusé. Pour chacune :

  • [field], [rejectedValue] : le champ et la valeur refusée ;
  • [defaultMessage] : le message d'erreur ; c'est le plus utile ;
  • [code] : le nom de la contrainte violée ([Size], [Email]...) ou [typeMismatch] pour une erreur de conversion ; [bindingFailure] vaut [true] pour une erreur de conversion (l'erreur est survenue pendant la liaison, avant la validation) ;
  • [codes], [arguments] : des clés et des valeurs qui permettent de chercher le message dans un fichier de messages. C'est ainsi que l'on remplacera le message anglais de l'erreur de conversion (clé [typeMismatch...]) par un message français, à l'exemple 19.

L'ordre des erreurs n'est pas garanti : il peut changer d'une exécution à l'autre. Même chose sans aucun paramètre :

curl -i http://localhost:5000/personnes/action01
HTTP/1.1 400
Content-Type: application/json

{
  "timestamp": "2026-09-25T16:46:41.574Z",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed for object='personneModele'. Error count: 3",
  "errors": [
    {
      "objectName": "personneModele",
      "field": "age",
      "rejectedValue": null,
      "codes": ["NotNull.personneModele.age","NotNull.age","NotNull.java.lang.Integer","NotNull"],
      "arguments": [{"arguments":null,"code":"age","codes":["personneModele.age","age"],"defaultMessage":"age"}],
      "bindingFailure": false,
      "code": "NotNull",
      "defaultMessage": "l'âge est obligatoire"
    },
    {
      "objectName": "personneModele",
      "field": "titre",
      "rejectedValue": null,
      "codes": ["NotNull.personneModele.titre","NotNull.titre","NotNull.java.lang.String","NotNull"],
      "arguments": [{"arguments":null,"code":"titre","codes":["personneModele.titre","titre"],"defaultMessage":"titre"}],
      "bindingFailure": false,
      "code": "NotNull",
      "defaultMessage": "le titre est obligatoire"
    },
    {
      "objectName": "personneModele",
      "field": "nom",
      "rejectedValue": null,
      "codes": ["NotBlank.personneModele.nom","NotBlank.nom","NotBlank.java.lang.String","NotBlank"],
      "arguments": [{"arguments":null,"code":"nom","codes":["personneModele.nom","nom"],"defaultMessage":"nom"}],
      "bindingFailure": false,
      "code": "NotBlank",
      "defaultMessage": "le nom est obligatoire"
    }
  ],
  "path": "/personnes/action01"
}

Un seul message par champ : les contraintes [@Size] et [@Pattern] acceptent la valeur [null], seules [@NotBlank] et [@NotNull] la refusent. Comme [@RequestParam], la liaison de données est sensible à la casse : avec [?NOM=martin&age=42&titre=Mr], on obtient l'erreur « le nom est obligatoire », car [NOM] ne correspond à aucun setter.

3.2.1. Pourquoi une classe JavaBean et pas un record ?

Depuis [Java] 16, un record décrit en une ligne une classe de données non modifiable : [record Point(int x, int y) {}] déclare les champs [x] et [y], un constructeur qui les reçoit tous, et les méthodes de lecture [x()] et [y()]. Un record n'a pas de setters. [Spring MVC] sait remplir un record : il appelle son constructeur avec les paramètres de la requête (on parle de liaison par constructeur). Remplaçons, pour voir, la classe [PersonneModele] par le record équivalent (l'action lit alors l'âge par [personne.age()]) :

public record PersonneModele(
    @NotBlank(message = "le nom est obligatoire")
    @Size(min = 2, max = 20, message = "le nom doit avoir de 2 à 20 caractères")
    String nom,
    @NotNull(message = "l'âge est obligatoire")
    @Min(value = 0, message = "l'âge doit être compris entre 0 et 150")
    @Max(value = 150, message = "l'âge doit être compris entre 0 et 150")
    Integer age,
    @NotNull(message = "le titre est obligatoire")
    @Pattern(regexp = "Mr|Mme|Melle", message = "titre inconnu")
    String titre,
    @Email(message = "adresse électronique invalide")
    String email) {
}

Une requête valide donne le même résultat, à l'ordre des propriétés JSON près : celles d'un record sont écrites dans l'ordre de ses composants ([{"nom":"martin","age":42,"titre":"Mr","email":null}]). Mais la requête invalide précédente ne donne plus qu'une erreur :

curl -i "http://localhost:5000/personnes/action01?nom=m&age=abc&titre=Dr&email=xx"
HTTP/1.1 400
Content-Type: application/json

{
  "timestamp": "2026-09-25T16:47:45.491Z",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed for object='personneModele'. Error count: 1",
  "errors": [
    {
      "arguments": [{"arguments":null,"code":"age","codes":["personneModele.age","age"],"defaultMessage":"age"}],
      "bindingFailure": true,
      "code": "typeMismatch",
      "codes": ["typeMismatch.personneModele.age","typeMismatch.age","typeMismatch.java.lang.Integer","typeMismatch"],
      "defaultMessage": "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'; For input string: \"abc\"",
      "field": "age",
      "objectName": "personneModele",
      "rejectedValue": "abc"
    }
  ],
  "path": "/personnes/action01"
}

La conversion de l'âge a échoué ; le constructeur du record n'a donc pas pu être appelé, faute de valeur pour l'un de ses paramètres ; sans objet, pas de validation : les erreurs sur le nom, le titre et l'adresse électronique ne sont pas signalées. Avec un JavaBean, au contraire, chaque setter est appelé indépendamment : un champ mal converti n'est pas rempli et reçoit une erreur de conversion, les autres sont remplis, et la validation a lieu (exemple 09). Pour un formulaire HTML, où l'on veut signaler toutes les erreurs d'un coup, c'est indispensable : tous les formulaires de ce cours sont donc des JavaBeans. Un record reste parfait pour un modèle dont les champs ne peuvent pas avoir d'erreur de conversion : c'est le cas de l'exemple 10.

Ce refus automatique convient à un serveur JSON, interrogé par un client [JavaScript] qui affiche les erreurs à sa façon. Pour une application HTML, un JSON d'erreur n'a aucun sens : on veut réafficher le formulaire avec ses erreurs. L'exemple suivant montre comment garder la main.

3.3. Exemple 09 : la validation faite par l'action

La classe [PersonneModele] de l'exemple 09 est identique à celle de l'exemple 08 ; seul le contrôleur change. Le fichier [controllers/PersonnesController.java] :

package exemple09.controllers;

import java.util.stream.Collectors;

import org.springframework.http.MediaType;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import exemple09.models.PersonneModele;
import jakarta.validation.Valid;

@RestController
@RequestMapping("/personnes")
public class PersonnesController {

    // un paramètre BindingResult, juste après le modèle : l'action est TOUJOURS
    // exécutée, et c'est elle qui décide de ce qu'elle fait d'un modèle invalide
    @GetMapping(value = "/action01", produces = MediaType.TEXT_HTML_VALUE)
    public String action01(@Valid PersonneModele personne, BindingResult resultat) {
        // resultat : le résultat de la liaison ET de la validation du modèle
        if (!resultat.hasErrors()) {
            return "<p>Bienvenue %s %s (%d ans)</p>".formatted(personne.getTitre(), personne.getNom(), personne.getAge());
        }
        // les erreurs, champ par champ
        String items = resultat.getFieldErrors().stream()
                .map(erreur -> "<li>%s : %s</li>".formatted(erreur.getField(), erreur.getDefaultMessage()))
                .collect(Collectors.joining());
        return "<p>Le formulaire contient des erreurs :</p><ul>" + items + "</ul>";
    }
}

Commentons ce code :

  • ligne 20 : l'attribut [produces] de [@GetMapping] fixe le type de la réponse : [MediaType.TEXT_HTML_VALUE] est la constante ["text/html"]. La chaîne rendue par l'action est donc envoyée comme une page HTML ;
  • ligne 21 : l'action a un second paramètre, de type [BindingResult], placé juste après le modèle validé. Sa présence change tout : en cas d'erreur de liaison ou de validation, [Spring MVC] ne lève plus d'exception ; il range les erreurs dans ce [BindingResult] et exécute l'action, qui décide de ce qu'elle en fait. [BindingResult] joue le rôle du [ModelState] d'[ASP.NET Core MVC] ;
  • lignes 23-25 : [hasErrors()] indique s'il y a des erreurs. S'il n'y en a pas, une page de bienvenue ([%d] est remplacé par un entier) ;
  • lignes 27-29 : sinon, la liste des erreurs. [getFieldErrors()] rend la liste des erreurs attachées à un champ ([FieldError]), avec le nom du champ ([getField()]) et le message ([getDefaultMessage()]). Le traitement utilise l'API des flux ([Stream]) de [Java] : [stream()] produit les éléments de la liste un par un, [map] transforme chacun d'eux (ici, une erreur en une ligne HTML [<li>]), [collect(Collectors.joining())] concatène le tout en une chaîne. L'argument de [map] est une lambda : une fonction anonyme, écrite [paramètre -> expression] ;
  • ligne 30 : une page qui liste les erreurs. Dans les deux cas, la réponse a le code 200.

Une requête valide, dans un navigateur :

Image

Une requête invalide :

Image

Une erreur de conversion apparaît dans [BindingResult] comme une erreur de validation, et n'empêche pas de valider les autres champs :

curl -i "http://localhost:5000/personnes/action01?nom=m&age=abc&titre=Mr"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
Content-Length: 262

<p>Le formulaire contient des erreurs :</p><ul><li>age : Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'age'; For input string: "abc"</li><li>nom : le nom doit avoir de 2 à 20 caractères</li></ul>

C'est cette technique qu'utiliseront tous les formulaires de l'étude de cas (cf. exemple 19) : l'action reçoit le formulaire (un JavaBean annoté [@Valid]) suivi d'un [BindingResult], et réaffiche le formulaire s'il y a des erreurs.

3.4. Exemple 10 : les paramètres multiples

Un même paramètre peut apparaître plusieurs fois dans une requête : c'est le cas des cases à cocher d'un formulaire, ou d'une liste à sélection multiple ([?couleur=rouge&couleur=vert]).

Le fichier [controllers/TableauxController.java] :

package exemple10.controllers;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import exemple10.models.ChoixModele;
import jakarta.validation.Valid;

@RestController
@RequestMapping("/tableaux")
public class TableauxController {

    // /tableaux/action01?couleur=rouge&couleur=vert : un paramètre simple
    @GetMapping("/action01")
    public Map<String, String> action01(@RequestParam(required = false) String couleur) {
        Map<String, String> reponse = new LinkedHashMap<>();
        reponse.put("couleur", couleur);
        return reponse;
    }

    // /tableaux/action02?couleur=rouge&couleur=vert : une liste
    @GetMapping("/action02")
    public Map<String, Object> action02(@RequestParam(required = false) List<String> couleur) {
        List<String> couleurs = couleur == null ? List.of() : couleur;
        Map<String, Object> reponse = new LinkedHashMap<>();
        reponse.put("couleur", couleurs);
        reponse.put("nombre", couleurs.size());
        return reponse;
    }

    // /tableaux/action03?couleur=rouge : une liste, validée par ChoixModele
    @GetMapping("/action03")
    public Map<String, Object> action03(@Valid ChoixModele choix, BindingResult resultat) {
        Map<String, Object> reponse = new LinkedHashMap<>();
        reponse.put("couleurs", choix.couleur());
        reponse.put("valide", !resultat.hasErrors());
        reponse.put("erreurs", resultat.getAllErrors().stream().map(ObjectError::getDefaultMessage).toList());
        return reponse;
    }
}

L'action [action01] (lignes 22-27) montre ce que reçoit un paramètre simple :

curl -i "http://localhost:5000/tableaux/action01?couleur=rouge"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 19

{"couleur":"rouge"}
curl -i "http://localhost:5000/tableaux/action01?couleur=rouge&couleur=vert"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 24

{"couleur":"rouge,vert"}

Un paramètre de type [String] qui reçoit plusieurs valeurs les concatène, séparées par des virgules : ["rouge,vert"]. C'est un piège : on croit recevoir une couleur, on en reçoit une liste déguisée. Un paramètre de type liste ([action02], lignes 30-37) les reçoit toutes, chacune dans un élément de la liste. [List<String>] est une liste de chaînes ; [List.of()] (ligne 32) une liste vide, qui remplace [null] quand le paramètre est absent :

curl -i "http://localhost:5000/tableaux/action02?couleur=rouge"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 32

{"couleur":["rouge"],"nombre":1}
curl -i "http://localhost:5000/tableaux/action02?couleur=rouge&couleur=vert"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 39

{"couleur":["rouge","vert"],"nombre":2}
curl -i "http://localhost:5000/tableaux/action02"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 25

{"couleur":[],"nombre":0}

Inversement, une valeur unique contenant des virgules est découpée :

curl -i "http://localhost:5000/tableaux/action02?couleur=rouge,vert"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 39

{"couleur":["rouge","vert"],"nombre":2}

La conversion d'une chaîne en liste, comme celle d'une liste en chaîne, utilise la virgule comme séparateur. On aurait pu aussi déclarer un tableau ([String[] couleur]). Reste à valider la liste. Le fichier [models/ChoixModele.java] :

package exemple10.models;

import java.util.List;

import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

// une liste, et une contrainte sur CHAQUE élément de la liste :
// l'annotation @Pattern est placée DANS le type générique List<...>
public record ChoixModele(

        @Size(min = 1, message = "choisissez au moins une couleur")
        List<@Pattern(regexp = "rouge|vert|bleu", message = "couleur inconnue : ${validatedValue}") String> couleur) {

    // le constructeur "compact" d'un record : une liste absente devient une liste vide
    public ChoixModele {
        couleur = couleur == null ? List.of() : couleur;
    }
}

Commentons ce code :

  • ligne 10 : [ChoixModele] est un record (cf. exemples 03 et 08) à un seul champ, [couleur]. La liaison par constructeur ne pose pas de problème ici : une liste de chaînes ne peut pas avoir d'erreur de conversion ;
  • ligne 12 : [@Size(min = 1)], appliquée à une liste, porte sur son nombre d'éléments : il en faut au moins un ;
  • ligne 13 : l'annotation [@Pattern] est placée à l'intérieur du type générique, devant [String] : elle ne s'applique pas à la liste, mais à chacun de ses éléments. Chaque couleur doit être [rouge], [vert] ou [bleu]. Dans le message, [${validatedValue}] est remplacé par la valeur refusée ;
  • lignes 16-18 : le constructeur compact d'un record : un bloc exécuté au début du constructeur, avant l'affectation des champs, qui peut modifier ses paramètres. Ici, une liste absente ([null]) devient une liste vide.

L'action [action03] du contrôleur [TableauxController] reçoit un [ChoixModele] validé, suivi d'un [BindingResult], et rend son contenu avec l'état de la validation. [choix.couleur()] lit le champ du record. [resultat.getAllErrors()] rend toutes les erreurs, et [ObjectError::getDefaultMessage] est une référence de méthode : une écriture abrégée de la lambda [erreur -> erreur.getDefaultMessage()]. [toList()] rassemble le flux dans une liste.

curl -i "http://localhost:5000/tableaux/action03?couleur=rouge&couleur=vert"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 56

{"couleurs":["rouge","vert"],"valide":true,"erreurs":[]}
curl -i "http://localhost:5000/tableaux/action03?couleur=rouge&couleur=noir"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 82

{"couleurs":["rouge","noir"],"valide":false,"erreurs":["couleur inconnue : noir"]}
curl -i "http://localhost:5000/tableaux/action03"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 76

{"couleurs":[],"valide":false,"erreurs":["choisissez au moins une couleur"]}

[ASP.NET Core MVC] avait besoin d'une méthode de validation écrite en [C#] pour vérifier chaque élément de la liste ; avec [Bean Validation], une annotation dans le type générique suffit.

3.5. Exemple 11 : les paramètres d'une requête POST

Un formulaire HTML envoyé par la méthode POST place ses champs dans le corps de la requête, et non dans l'URL. Le fichier [controllers/PostController.java] :

package exemple11.controllers;

import java.util.LinkedHashMap;
import java.util.Map;

import org.springframework.util.MultiValueMap;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import exemple11.models.PersonneModele;
import jakarta.validation.Valid;
import tools.jackson.databind.JsonNode;

@RestController
@RequestMapping("/post")
public class PostController {

    // les champs d'un formulaire HTML (corps application/x-www-form-urlencoded)
    @PostMapping("/action01")
    public Map<String, Object> action01(@RequestHeader("Content-Type") String type,
            @RequestParam MultiValueMap<String, String> champs) {
        Map<String, Object> reponse = new LinkedHashMap<>();
        reponse.put("typeRecu", type);
        reponse.put("champs", champs);
        return reponse;
    }

    // un corps JSON : @RequestBody le désérialise
    @PostMapping("/action02")
    public Map<String, Object> action02(@RequestHeader("Content-Type") String type, @RequestBody JsonNode corps) {
        Map<String, Object> reponse = new LinkedHashMap<>();
        reponse.put("typeRecu", type);
        reponse.put("corps", corps);
        return reponse;
    }

    // un seul champ du formulaire : @RequestParam, comme pour un paramètre de l'URL
    @PostMapping("/action03")
    public String action03(@RequestParam(required = false) String nom) {
        return "nom = [%s]".formatted(nom);
    }

    // les champs du formulaire, liés et validés par la classe PersonneModele
    @PostMapping("/action04")
    public Map<String, Object> action04(@Valid PersonneModele personne, BindingResult resultat) {
        Map<String, Object> reponse = new LinkedHashMap<>();
        reponse.put("personne", personne);
        reponse.put("valide", !resultat.hasErrors());
        reponse.put("erreurs", resultat.getAllErrors().stream().map(ObjectError::getDefaultMessage).sorted().toList());
        return reponse;
    }
}

Commentons ce code :

  • ligne 25 : [@PostMapping] associe l'action aux requêtes POST, comme [@GetMapping] aux requêtes GET ;
  • ligne 26 : [@RequestHeader("Content-Type")] fournit la valeur d'un entête de la requête : ici le type de son corps ;
  • ligne 27 : un paramètre de type [MultiValueMap<String, String>] annoté [@RequestParam] reçoit tous les paramètres de la requête. Un [MultiValueMap] est un dictionnaire dont chaque clé est associée à une liste de valeurs : il conserve toutes les valeurs d'un paramètre répété, alors que le [Map<String, String>] de l'exemple 07 n'en garderait qu'une ;
  • ligne 36 : [@RequestBody] demande de lire le corps de la requête et de le désérialiser, c'est-à-dire de le transformer en objet [Java]. Le corps doit être un document JSON (c'est ce qu'envoie un client [JavaScript]), lu par la bibliothèque [Jackson]. [JsonNode] représente un document JSON quelconque ; on aurait pu déclarer une classe ou un record. Ligne 18 : dans [Jackson] 3, utilisé par [Spring Boot] 4, les classes sont dans le package [tools.jackson.databind] (et non plus [com.fasterxml.jackson.databind] comme dans [Jackson] 2) ;
  • lignes 44-45 : [@RequestParam] lit un champ du formulaire exactement comme un paramètre de l'URL ;
  • lignes 50-51 : le formulaire entier, lié et validé par la classe [PersonneModele] (une copie, dans le package [exemple11.models], de celle de l'exemple 08) (un objet de commande, comme à l'exemple 09) ;
  • ligne 55 : [sorted()] trie les messages, pour que l'ordre des erreurs ne change pas d'une exécution à l'autre.

L'option [-d] de [curl] envoie une requête POST avec un corps au format d'un formulaire HTML ([application/x-www-form-urlencoded]) :

curl -i -d "nom=martin&age=42" http://localhost:5000/post/action01
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 89

{"typeRecu":"application/x-www-form-urlencoded","champs":{"nom":["martin"],"age":["42"]}}

Chaque champ est associé à une liste de valeurs, qui n'en a ici qu'une. Le même contenu au format JSON :

curl -i -H "Content-Type: application/json" -d '{"nom":"martin","age":42}' http://localhost:5000/post/action02
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 65

{"typeRecu":"application/json","corps":{"nom":"martin","age":42}}

(l'option [-H] de [curl] ajoute un entête à la requête ; sous [Windows], dans l'invite de commandes, on écrira [-d "{\"nom\":\"martin\",\"age\":42}"]). L'âge est une chaîne dans le premier cas (un formulaire HTML ne transporte que du texte) et un nombre dans le second. [Spring MVC] choisit le convertisseur qui lit le corps d'après l'entête [Content-Type] de la requête. Une action [@RequestBody] qui attend du JSON refuse donc un corps d'un autre type, par une erreur 415 ([Unsupported Media Type], type de contenu non pris en charge) :

curl -i -d "nom=martin&age=42" http://localhost:5000/post/action02
1
2
3
4
5
HTTP/1.1 415
Accept: application/json, application/*+json
Content-Type: application/json

{"timestamp":"2026-09-25T16:48:45.851Z","status":415,"error":"Unsupported Media Type","message":"Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported.","path":"/post/action02"}

L'entête [Accept] de la réponse indique les types acceptés. Un corps JSON mal formé donne, lui, une erreur 400 :

curl -i -H "Content-Type: application/json" -d '{"nom":"martin",' http://localhost:5000/post/action02
1
2
3
4
HTTP/1.1 400
Content-Type: application/json

{"timestamp":"2026-09-25T16:48:45.879Z","status":400,"error":"Bad Request","message":"JSON parse error: Unexpected end-of-input within/between Object entries","path":"/post/action02"}

Les deux autres actions :

curl -i -d "nom=martin&age=42" http://localhost:5000/post/action03
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 14

nom = [martin]
curl -i -d "nom=martin&age=42&titre=Mme" http://localhost:5000/post/action04
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 92

{"personne":{"age":42,"email":null,"nom":"martin","titre":"Mme"},"valide":true,"erreurs":[]}
curl -i -d "nom=martin&age=200" http://localhost:5000/post/action04
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 162

{"personne":{"age":200,"email":null,"nom":"martin","titre":null},"valide":false,"erreurs":["l'âge doit être compris entre 0 et 150","le titre est obligatoire"]}

[@RequestParam] et les objets de commande ne distinguent pas l'URL du corps : ils voient ensemble les paramètres de l'URL et les champs d'un formulaire. Un paramètre présent aux deux endroits reçoit donc deux valeurs :

curl -i "http://localhost:5000/post/action03?nom=durand" -d "nom=martin"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 21

nom = [durand,martin]

Une action POST répond avec le code 200, comme une action GET. Pour une application HTML, une action POST répond soit par une page (formulaire réaffiché), soit par une redirection (cf. exemple 20).

3.6. Exemple 12 : liaison et validation personnalisées

On peut étendre la liaison de données et la validation. L'étude de cas en fait un usage modéré, mais les connaître aide à comprendre le fonctionnement de [Spring MVC].

12-liaison-perso/
├── src/main/java/exemple12/
│   ├── controllers/
│   │   └── ExemplesController.java
│   ├── liaison/
│   │   ├── DateFrConverter.java
│   │   ├── Langue.java
│   │   ├── LangueResolver.java
│   │   ├── SansEspace.java
│   │   ├── SansEspaceValidator.java
│   │   └── WebConfig.java
│   └── Exemple12Application.java
├── src/main/resources/
│   └── application.properties
└── pom.xml

3.6.1. Un convertisseur : DateFrConverter

Pour convertir la chaîne ["42"] en [int], [Spring MVC] utilise un convertisseur ([Converter]). Il en fournit pour tous les types courants ; on peut écrire les siens. Le fichier [liaison/DateFrConverter.java] :

package exemple12.liaison;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;

import org.springframework.core.convert.converter.Converter;

// un convertisseur : transforme une date française "23/09/2026" en LocalDate.
// Il sera utilisé pour TOUT paramètre de type LocalDate (cf. WebConfig)
public class DateFrConverter implements Converter<String, LocalDate> {

    // uuuu : l'année ; STRICT : "31/02/2026" est refusée (et non ramenée au 28 février)
    private static final DateTimeFormatter FORMAT =
            DateTimeFormatter.ofPattern("dd/MM/uuuu").withResolverStyle(ResolverStyle.STRICT);

    @Override
    public LocalDate convert(String texte) {
        try {
            return LocalDate.parse(texte, FORMAT);
        } catch (DateTimeParseException e) {
            // échec : une exception, que Spring transforme en erreur de conversion
            throw new IllegalArgumentException("[" + texte + "] n'est pas une date jj/mm/aaaa valide");
        }
    }
}
  • ligne 12 : un convertisseur implémente l'interface générique [Converter<S, T>], où [S] est le type source et [T] le type cible : ici, de [String] vers [LocalDate] (une date sans heure) ;
  • lignes 15-16 : le format des dates, [jj/mm/aaaa]. En mode [STRICT], une date qui n'existe pas (31/02/2026) est refusée ; ce mode impose d'écrire l'année [uuuu] et non [yyyy] ;
  • lignes 19-21 : l'unique méthode de l'interface, [convert], rend la date obtenue ;
  • lignes 22-25 : en cas d'échec, le convertisseur lève une exception. [Spring MVC] la transforme en erreur de conversion, exactement comme pour [?age=abc] : erreur 400 pour un [@RequestParam], erreur [typeMismatch] dans le [BindingResult] pour un champ d'objet de commande. Notre exception est conservée comme cause de l'erreur de conversion : nous récupérerons son message dans le contrôleur.

3.6.2. Un argument resolver : l'annotation @Langue et LangueResolver

Un paramètre d'action peut venir d'ailleurs que de l'URL ou du formulaire. Pour chaque paramètre d'une action, [Spring MVC] interroge une liste d'objets appelés [argument resolvers] (« résolveurs d'arguments ») : le premier qui déclare savoir traiter le paramètre en fournit la valeur. C'est ainsi que fonctionnent [@RequestParam], [@RequestHeader], [@RequestBody]... On peut ajouter les siens. Il faut d'abord un moyen de désigner les paramètres concernés : une annotation « maison ». Le fichier [liaison/Langue.java] :

package exemple12.liaison;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

// une annotation "maison" : @Langue String langue. Elle ne fait rien par
// elle-même : elle marque un paramètre, que LangueResolver saura remplir.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Langue {
}
  • ligne 12 : [@interface] déclare une annotation. Celle-ci n'a aucun attribut ;
  • ligne 10 : [@Target] indique où l'annotation peut être placée : ici, uniquement sur un paramètre de méthode ;
  • ligne 11 : [@Retention(RetentionPolicy.RUNTIME)] : l'annotation est conservée dans le code compilé et lisible à l'exécution. C'est indispensable pour que [Spring MVC] la voie.

Le fichier [liaison/LangueResolver.java] :

package exemple12.liaison;

import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

// un "argument resolver" : il fournit la valeur des paramètres annotés @Langue,
// lue dans l'entête "Accept-Language: fr-FR,fr;q=0.9,en;q=0.8" -> "fr"
public class LangueResolver implements HandlerMethodArgumentResolver {

    // ce resolver s'occupe-t-il de ce paramètre ?
    @Override
    public boolean supportsParameter(MethodParameter parametre) {
        return parametre.hasParameterAnnotation(Langue.class) && parametre.getParameterType() == String.class;
    }

    // la valeur du paramètre
    @Override
    public Object resolveArgument(MethodParameter parametre, ModelAndViewContainer mavContainer,
            NativeWebRequest requete, WebDataBinderFactory binderFactory) {
        String entete = requete.getHeader("Accept-Language");
        String premiere = entete == null ? "" : entete.split(",")[0].trim();
        return premiere.length() >= 2 ? premiere.substring(0, 2).toLowerCase() : "fr";
    }
}
  • ligne 11 : un résolveur implémente l'interface [HandlerMethodArgumentResolver] ;
  • lignes 14-17 : [supportsParameter] rend [true] pour les paramètres dont il s'occupe : ceux qui portent l'annotation [@Langue] et sont de type [String]. [MethodParameter] décrit un paramètre de l'action ;
  • lignes 20-26 : [resolveArgument] fournit la valeur du paramètre. Parmi ses arguments, [NativeWebRequest] donne accès à la requête. Ligne 23 : l'entête [Accept-Language], qui indique les langues préférées de l'utilisateur. Lignes 24-25 : les deux premières lettres de la première langue, en minuscules, ou ["fr"] si l'entête manque. L'opérateur [? :] rend sa deuxième opérande si la condition est vraie, la troisième sinon.

3.6.3. Une contrainte de validation : @SansEspace

Une contrainte de validation personnalisée se compose de deux éléments : une annotation et la classe qui fait la vérification. Le fichier [liaison/SansEspace.java] :

package exemple12.liaison;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

// une contrainte de validation "maison" : @SansEspace.
// @Constraint désigne la classe qui fait la vérification.
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SansEspaceValidator.class)
public @interface SansEspace {

    // le message d'erreur par défaut
    String message() default "le champ ne doit pas contenir d'espace";

    // deux attributs exigés par la norme Bean Validation (rarement utilisés)
    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
  • ligne 13 : la contrainte peut être placée sur un champ de classe ou sur un paramètre de méthode ;
  • ligne 15 : [@Constraint] fait de l'annotation une contrainte [Bean Validation], et désigne la classe qui la vérifie ;
  • ligne 19 : le message d'erreur par défaut. Un attribut d'annotation se déclare comme une méthode sans paramètre, et [default] lui donne une valeur par défaut : on écrira [@SansEspace] ou [@SansEspace(message = "...")] ;
  • lignes 22-24 : deux attributs exigés par la norme : [groups] permet de ne vérifier qu'un sous-ensemble des contraintes, [payload] d'attacher des informations à une contrainte. Nous ne les utilisons pas, mais ils doivent être déclarés.

Le fichier [liaison/SansEspaceValidator.java] :

package exemple12.liaison;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

// la vérification de la contrainte @SansEspace, pour une valeur de type String
public class SansEspaceValidator implements ConstraintValidator<SansEspace, String> {

    @Override
    public boolean isValid(String valeur, ConstraintValidatorContext contexte) {
        // null est accepté : c'est le rôle de @NotNull de l'exiger
        return valeur == null || !valeur.contains(" ");
    }
}
  • ligne 7 : la classe de vérification implémente [ConstraintValidator<A, T>], où [A] est l'annotation et [T] le type des valeurs vérifiées ;
  • lignes 10-13 : [isValid] rend [true] si la valeur est acceptable. La valeur [null] est acceptée, comme par toutes les contraintes standard sauf [@NotNull] et [@NotBlank].

3.6.4. La configuration : WebConfig

Le convertisseur et le résolveur doivent être déclarés à [Spring MVC] ; la contrainte, elle, est trouvée grâce à l'annotation [@Constraint]. Le fichier [liaison/WebConfig.java] :

package exemple12.liaison;

import java.util.List;

import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

// WebMvcConfigurer : une classe de configuration qui complète celle de Spring MVC.
// On redéfinit les méthodes qui nous intéressent.
@Configuration
public class WebConfig implements WebMvcConfigurer {

    // les convertisseurs : String -> LocalDate
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new DateFrConverter());
    }

    // les "argument resolvers" : les paramètres @Langue
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LangueResolver());
    }
}
  • ligne 12 : [@Configuration] désigne une classe de configuration : [Spring Boot] la découvre au démarrage (elle est dans un sous-package de celui de la classe [Exemple12Application]), en crée une instance et l'utilise ;
  • ligne 13 : l'interface [WebMvcConfigurer] permet de compléter la configuration de [Spring MVC] établie par [Spring Boot], sans la remplacer. Elle a de nombreuses méthodes, qui ne font rien par défaut ; on redéfinit ([@Override]) celles qui nous intéressent ;
  • lignes 16-19 : [addFormatters] enregistre des convertisseurs. [DateFrConverter] sera utilisé pour tout paramètre de type [LocalDate] de l'application ;
  • lignes 22-25 : [addArgumentResolvers] ajoute des résolveurs à la liste de ceux de [Spring MVC].

3.6.5. Le contrôleur

Le fichier [controllers/ExemplesController.java] :

package exemple12.controllers;

import java.time.LocalDate;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import exemple12.liaison.Langue;
import exemple12.liaison.SansEspace;

@RestController
@RequestMapping("/exemples")
public class ExemplesController {

    // /exemples/agenda?jour=23/09/2026 : un convertisseur personnalisé (DateFrConverter)
    @GetMapping("/agenda")
    public String agenda(@RequestParam LocalDate jour) {
        return "agenda du jour [" + jour + "]";
    }

    // /exemples/bonjour : un paramètre qui ne vient pas de l'URL, mais d'un entête
    @GetMapping("/bonjour")
    public String bonjour(@Langue String langue) {
        return "en".equals(langue) ? "Hello!" : "Bonjour !";
    }

    // /exemples/recherche?motCle=spring : une contrainte de validation personnalisée,
    // posée directement sur un paramètre de l'action
    @GetMapping("/recherche")
    public String recherche(@RequestParam(required = false) @SansEspace String motCle) {
        return "recherche de [" + motCle + "]";
    }

    // --------------------------------------------------------------- erreurs
    // @ExceptionHandler : une méthode du contrôleur qui transforme une exception
    // levée par l'une de ses actions (ou par la liaison de ses paramètres) en réponse

    // une conversion impossible (DateFrConverter a levé une exception)
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String conversion(MethodArgumentTypeMismatchException e) {
        // l'exception d'origine, celle de DateFrConverter : son message est le nôtre
        return e.getMostSpecificCause().getMessage();
    }

    // un paramètre qui viole une contrainte de validation (@SansEspace)
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String validation(HandlerMethodValidationException e) {
        return e.getAllErrors().get(0).getDefaultMessage();
    }
}
  • ligne 24 : un paramètre ordinaire de type [LocalDate] : rien n'indique qu'il faut utiliser [DateFrConverter], c'est son type qui le désigne ;
  • ligne 30 : le paramètre [langue] est fourni par [LangueResolver], grâce à l'annotation [@Langue] ;
  • ligne 37 : une contrainte de validation peut aussi s'appliquer directement à un paramètre d'action (ici, avec [@RequestParam]). Il n'y a pas besoin de [@Valid] : [Spring MVC] valide automatiquement les paramètres qui portent une contrainte. En cas d'échec, il lève l'exception [HandlerMethodValidationException] ;
  • lignes 46-51 : une méthode annotée [@ExceptionHandler] est un gestionnaire d'exception : si une action du contrôleur, ou la liaison de ses paramètres, lève une exception du type de son paramètre (ici [MethodArgumentTypeMismatchException], l'échec d'une conversion), [Spring MVC] l'appelle au lieu de produire sa réponse d'erreur. Ce qu'elle rend devient la réponse, avec le code fixé par [@ResponseStatus] : 400 ([HttpStatus.BAD_REQUEST]). Ligne 50 : [getMostSpecificCause()] rend l'exception d'origine, celle levée par [DateFrConverter], qui contient notre message ;
  • lignes 54-58 : de même pour l'échec de la validation d'un paramètre. [getAllErrors()] rend la liste des erreurs, dont on garde le message de la première.

Sans ces deux gestionnaires, la réponse serait le JSON d'erreur de [Spring Boot], avec, pour la date, un message technique en anglais qui ne reprend pas le nôtre. Un gestionnaire d'exception ne vaut que pour son contrôleur ; nous verrons à l'exemple 24 comment en écrire pour toute l'application ([@ControllerAdvice]).

Les résultats :

curl -i "http://localhost:5000/exemples/agenda?jour=23/09/2026"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 27

agenda du jour [2026-09-23]
curl -i "http://localhost:5000/exemples/agenda?jour=2026-09-23"
1
2
3
4
5
HTTP/1.1 400
Content-Type: text/plain;charset=UTF-8
Content-Length: 49

[2026-09-23] n'est pas une date jj/mm/aaaa valide
curl -i "http://localhost:5000/exemples/agenda?jour=31/02/2026"
1
2
3
4
5
HTTP/1.1 400
Content-Type: text/plain;charset=UTF-8
Content-Length: 49

[31/02/2026] n'est pas une date jj/mm/aaaa valide

Une date [LocalDate] s'écrit [2026-09-23] quand on la convertit en chaîne (format ISO). La langue :

curl -i -H "Accept-Language: fr-FR,fr;q=0.9" http://localhost:5000/exemples/bonjour
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 9

Bonjour !
curl -i -H "Accept-Language: en-US,en;q=0.9" http://localhost:5000/exemples/bonjour
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 6

Hello!

La contrainte [@SansEspace] :

curl -i "http://localhost:5000/exemples/recherche?motCle=spring"
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 21

recherche de [spring]
curl -i "http://localhost:5000/exemples/recherche?motCle=spring%20mvc"
1
2
3
4
5
HTTP/1.1 400
Content-Type: text/plain;charset=UTF-8
Content-Length: 38

le champ ne doit pas contenir d'espace

([%20] est le codage d'un espace dans une URL)

3.6.6. Conclusion

Le modèle d'une action est construit par la liaison de données à partir de la requête : paramètres de l'URL et champs du formulaire ([@RequestParam], objet de commande), corps JSON ([@RequestBody]), entêtes ([@RequestHeader]), et tout ce qu'un résolveur personnalisé sait fournir. Les valeurs sont converties dans le type des paramètres, puis validées par les contraintes [Bean Validation] quand le modèle est annoté [@Valid]. Une erreur de conversion d'un [@RequestParam], ou un modèle invalide sans [BindingResult], donne une erreur 400 sans exécuter l'action : cela convient à un serveur JSON. Pour une application HTML, le formulaire est un JavaBean annoté [@Valid], suivi d'un paramètre [BindingResult] que l'action consulte (exemple 09), afin de pouvoir réafficher le formulaire avec ses erreurs. C'est l'objet du chapitre suivant.