Accéder au contenu principal

Comment valider les données utilisateur dans votre application Java : Guide pratique avec Jakarta Bean Validation

Lorsque vous développez une application qui interagit avec un utilisateur, en lui demandant de saisir des données, il est primordial de vérifier et valider ces données avant tout traitement. Et ceci pour plusieurs raisons: vous aurez la possibilité d'éviter des traitements inutiles si les données reçues ne sont pas conformes aux données attendues et aussi cela permet de protéger votre application en cas d'attaque orchestrée par des hackers. Dans le langage java, il existe une spécification qui vous permet de valider tout type de données transmis dans un bean: Java Bean Validation (ou plus récemment: Jakarta Bean Validation). Dans ce tutoriel, au travers d'une petite application de gestion d'une bibliothèque de quartier, nous allons voir ensemble comment utiliser Jakarta Bean Validation.


1. Pourquoi Jakarta Bean Validation

L'objectif de Jakarta Bean Validation est de standardiser la validation des champs au travers de specification (JSR 380). Aussi cette standardisation a pour but de bien isoler la validation des bean dans des classes et annotations dédiées.
Jakarta Bean Validation (autrement Java Bean Validation) met à disposition plusieurs possibilités de validation des données (champs de bean, paramètre de méthodes, valeur de retour d'une méthode, etc...)

L'implémentation de référence de cette spécification est Hibernate Validator. Cette implémentation tirera la dépendance sur Jakarta Bean Validation API.

2. Les dépendances

Dépendances Gradle
implementation 'org.hibernate.validator:hibernate-validator:8.0.1.Final'
implementation 'org.glassfish.expressly:expressly:5.0.0'

Dépendances Maven
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>8.0.1.Final</version>
</dependency>

<dependency>
    <groupId>org.glassfish.expressly</groupId>
    <artifactId>expressly</artifactId>
    <version>5.0.0</version>
</dependency>

La dépendance 'expressly' est utile à Hibernate Validator pour l'interprétation et l'évaluation des messages d'erreurs résultants d'une validation de données.

3. La validation des champs d'un bean


Dans notre application, nous avons besoin de définir un utilisateur avec trois champs avec des contraintes:
  • Le nom: non null et non vide
  • Le prénom: non null et non vide
  • L'email: non null et non vide et ayant le format email
  • La date de naissance: non null et doit être dans le passé
Au vu de ces éléments, nous allons utiliser Jakarta Bean Validation pour exprimer ces contraintes, puis ensuite valider ces contraintes dans le fonctionnement de l'application comme suit
  public record User(
    @NotBlank
    String name,

    @NotBlank
    String firstName,

    @NotBlank
    @Email
    String email,

    @NotNull
    @Past
    LocalDateTime birthDate
) {}
Valider les contraintes se fait en plusieurs étapes:
D'abord il faut commencer par  instancier un validator comme suit:
 Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
Puis invoker sa méthode validate comme ceci:
 Set<ConstraintViolation<User>> constraintsViolations = validator.validate(user);
Et enfin exploiter le résultat de la validation comme suit:
  constraintsViolations.forEach(c -> {
     String message = c.getMessage();
     String path = c.getPropertyPath().toString();
  });
Le validator permet de valider toutes les contraintes disponibles. Ainsi au final, l'important dans la validation n'est pas l'écriture d'un validator, mais la mise en place des contraintes afin que le validator puisse faire son travail

4. Gestion des messages d'erreur

Lors de la déclaration des contraintes, si aucun message n'est indiqué, Jakarta bean Validation vous affichera des messages par défaut comme ceci:
  • @NotNull: Must not be null
  • @NotBlank: Must not be blank
Et ainsi de suite.

La personnalisation des messages d'erreur se fait via l'attribut message de l'annotation de la contrainte comme suit:
    public record User(
      @NotBlank(message = "Le nom de l'utilisateur ne peut pas être vide")
      String name,

      @NotBlank(message = "Le prénom de l'utilisateur ne peut pas être vide")
      String firstName,

      @NotBlank(message = "L'email de l'utilisateur ne peut pas être vide")
      @Email(message = "L'email de l'utilisateur n'a pas le bon format")
      String email,

      @NotNull(message = "La date d'anniversaire de l'utilisateur ne peut pas être vide")
      @Past(message = "La date d'anniversaire de l'utilisateur doit être une date passée")
      LocalDateTime birthDate
    ) {
    }
Dans ce exemple les messages d'erreurs sont directement lié à l'objet. La bonne pratique est de ne pas mettre directement le message d'erreur dans l'objet, mais plutôt d'utiliser un fichier bundle pour afficher les messages. Cette approche permet non seulement de séparer les messages d'erreur de la donnée (ici l'objet user), mais aussi vous permet d'afficher les messages d'erreur dans plusieurs langues si vous le souhaitez.

Pour se faire, il faut créer le fichier ValidationMessages.properties dans le répertoire resources de votre projet de sorte qu'il se retrouve dans le classpath de votre application et également à la racine des packages de votre application. Ce fichier sera le fichier bundle par défaut utilisé par Jakarta Bean Validation lorsque le locale (langue) de votre JVM n'est pas indiquée. Les messages sont donc représenté par des clés de properties comme suit:
    public record User(
      @NotBlank(message = "{user.name.NotBlank.message}")
      String name,

      @NotBlank(message = "{user.firstName.NotBlank.message}")
      String firstName,

      @NotBlank(message = "{user.email.NotBlank.message}")
      @Email(message = "{user.email.Email.message}")
      String email,

      @NotNull(message = "{user.birthDate.NotNull.message}")
      @Past(message = "{user.birthDate.Past.message}")
      LocalDateTime birthDate
  ) {
  } 
Puis dans le fichier bundle (ValidationMessages.properties), rajouter ceci:
user.name.NotBlank.message=Le nom de l'utilisateur ne peut pas être vide
user.firstName.NotBlank.message=Le prénom de l'utilisateur ne peut pas être vide
user.email.NotBlank.message=L'email de l'utilisateur ne peut pas être vide
user.email.Email.message=L'email de l'utilisateur n'a pas le bon format
user.birthDate.NotNull.message=La date d'anniversaire de l'utilisateur ne peut pas être vide
user.birthDate.Past.message=La date d'anniversaire de l'utilisateur doit être une date passée
Pour afficher les messages dans une autre langue, il suffit de créer un nouveau fichier bundle dans le même emplacement que le précédent, mais cette fois ci avec la langue en extension. A titre d'exemple, pour traduire les memes messages en anglais, en espagnol, ou en allemand, il faudrait créer ces fichiers:
  • ValidationMessages_en.properties : pour l'anglais
  • ValidationMessages_es.properties : pour l'espagnol
  • ValidationMessages_de.properties : pour l'allemand
  • etc.
Et y mettre les meme clés précédemment vu, en les traduisant dans la bonne langue.

5. La validation imbriquée

Cette fois ci, notre objet métier Book est celui ci:
public record Book(
    @NotBlank
    String id,

    @NotBlank
    String isbn,

    @NotBlank
    @Size(min = 5)
    String title,

    @NotBlank
    @Size(max = 100)
    String summary,

    @Min(1)
    int pages,

    @NotNull
    @Past
    LocalDateTime publicationDate,

    @NotNull
    Author author,

    @NotNull
    User owner
) {
}
Lorsque nous validons cet objet, toutes les contraintes sont traités par le validator. Seulement il se trouve que User et Author sont aussi des objets ayant des contraintes. Mais dans la situation actuelle, leurs contraintes ne seront pas traitées. Afin de pouvoir demander la validation des contraintes de Author et User, lorsque l'objet Book est validé par le validator, il va nous falloir utiliser l'annotation @Valid comme suit:
public record Book(
    @NotBlank
    String id,

    @NotBlank
    String isbn,

    @NotBlank
    @Size(min = 5)
    String title,

    @NotBlank
    @Size(max = 100)
    String summary,

    @Min(1)
    int pages,

    @NotNull
    @Past
    LocalDateTime publicationDate,

    @NotNull
    @Valid
    Author author,

    @NotNull
    @Valid
    User owner
) {
}

6. La validation des paramètres d'une méthode / d'un constructeur

Au même titre que les champs d'un bean, les paramètres d'une méthode peuvent être contraintes avec les mêmes annotations que celles utilisées précédemment. Ces contraintes sont également valables pour au niveau des constructeurs d'une classe:
  • Contrainte sur les paramètres d'une méthode
public interface ICreateBookComponent {
    Book createBook(@NotNull Book book, @Min(1) int count) throws CreateBookException;
}
  • Contraintes sur les paramètres d'un constructeur
 public CreateBookComponent(@NotNull ICreateBookPort createBookPort){
 }
Lorsqu'une même contrainte est applicable à tous les paramètres d'une méthode ou d'un constructeur, elle doit alors être indiqué au niveau de la méthode ou du constructeur comme suit:
@NotNull
public Book createBook(Book book, Author author, User owner) throws CreateBookException {
  ...
}
Les contraintes une fois définies doivent maintenant être validées via le validator. Dans le contexte des paramètres d'une méthode ou d'un constructeur, un autre type de validator doit être utilisé. De plus, la validation doit se faire via la "reflexion" java afin que cela ne fonctionne. D'abord commencer par créer le validator:
  ExecutableValidator validator = 
  Validation.buildDefaultValidatorFactory().getValidator().forExecutables();
Puis exécuter la méthode validation qui nous intéresse en fonction du besoin:
  • Pour la validation des paramètres d'une méthode
User user =  new User(null, null, null, null);
Book book = new Book(null, null, null, null, -1, null, null, user);

ICreateBookComponent createBookComponent = new CreateBookComponent((book1, count) -> null);
Method method = ICreateBookComponent.class.getMethod("createBook", Book.class, int.class);

validator.validateParameters(createBookComponent, method, new Object[] {book, 0});
  • Pour la validation des paramètres d'un constructeur
Constructor<CreateBookComponent> constructor = 
CreateBookComponent.class.getConstructor(ICreateBookPort.class);

validator.validateConstructorParameters(constructor, new Object[]{null});

7. La validation des valeurs de retour d'une méthode / d'un constructeur

Plusieurs contraintes peuvent être mises sur les valeurs de retour d'une méthode ou sur la valeur de retour d'un constructeur:
  • Contrainte sur les valeurs de retour d'une méthode
public interface ICreateBookComponent {
    @NotNull
    Book createBook(Book book, int count) throws CreateBookException;
}
  • Contrainte sur la valeur de retour d'un constructeur
Dans ce contexte, les contraintes doivent être positionnés sur le constructeur

@CheckedCreateBookComponent
public CreateBookComponent(ICreateBookPort createBookPort){
 ...
}
Après avoir mis les contraintes, il est temps de valider ces contraintes via le validator. Nous créons donc le validator, comme nous l'avons fait précédemment dans le chapitre: 6, puis invoquons les méthodes en fonction de nos besoins.
  • Pour valider la valeur de retour d'une méthode
User user =  new User(null, null, null, null);
Book book = new Book(null, null, null, null, -1, null, null, user);

ICreateBookComponent createBookComponent = new CreateBookComponent((book1, count) -> null);
Method method = ICreateBookComponent.class.getMethod("createBook", Book.class, int.class);

Object returnValue = method.invoke(createBookComponent, book, 1);
validator.validateReturnValue(createBookComponent, method, returnValue);
  • Pour valider la valeur de retour d'un constructeur
Constructor<CreateBookComponent> constructor =
        CreateBookComponent.class.getConstructor(ICreateBookPort.class);
ICreateBookComponent createBookComponent = new CreateBookComponent((book1, count) -> null);

validator.validateConstructorReturnValue(constructor, createBookComponent);

IMPORTANT: Jakarta Bean Validation préconise fortement d'utiliser une technique d'interception de méthodes telles que AOP (Aspect Oriented Programming) ou Java Method Proxies lors de l'exécution de la validation  des paramètres ou de valeurs de retour de méthodes ou constructeurs

8. Les contraintes sur les types génériques

Il est aussi possible de mettre des contraintes sur les types génériques et de les valider comme nous l'avons vu précédemment, c'est à dire en fonction du contexte. Ces contraintes peuvent prendre les formes ci-dessous:
List<@NotNull @Valid User> users = ...
Map<@NotEmpty String, @Valid Author> authorMap = ...


9. Les contraintes existantes par défaut avec Jakarta Bean Validation

Contrainte Description
@AssertFalse La valeur ou le champs doit être égale à: false
@AssertTrue La valeur ou le champ doit être égale à: true
@DecimalMax La valeur maximale du décimal est être égale à la valeur indiquée
par la contrainte
@DecimalMin La valeur minimale du décimal est être égale à la valeur indiquée
par la contrainte
@Digits Définition des bornes maximales d'un décimal en indiquant le
nombre de chiffre
avant et après la virgule
@Email La valeur ou le  champ doit avoir la forme d'un email
@Future La date ou le time doit être dans le future
@FutureOrPresent La date ou le time doit être dans le présent ou le future
@Max La valeur maximale d'un champ entier
@Min La valeur minimale d'un champ entier
@Negative La valeur ou le champ doit être un nombre négatif
@NegativeOrZero La valeur ou le champ doit être un nombre inférieur ou égale
à zéro
@NotBlank La valeur ou le champ doit contenir au moins un caractère
non vide
@NotEmpty La valeur ou le champ ne doit pas être vide
@NotNull La valeur ou le champ ne doit pas être null
@Null La valeur ou le champ doit être null
@Past La date ou le time doit être dans le passé
@PastOrPresent La date ou le time doit être dans le présent ou le passé
@Pattern La valeur ou le champ doit respecter l'expression régulière
@Positive La valeur ou le champ doit être un nombre positif
@PositiveOrZero La valeur ou le champ doit être un nombre supérieur ou égale
à zéro
@Size La valeur ou le champ doit avoir une taille égale à la valeur
indiquée. s'applique aussi bien au string qu'aux collections
et aux map

10. Créer ses propres contraintes

Jakarta Bean Validation donne la possibilité de créer ses propres contraintes afin de couvrir tous vos besoins et rendre plus efficaces la validations des données dans votre application. Cette création de contrainte personnalisée se fait en deux étapes principales:
  • Définir l'annotation de la contrainte
Nous allons définir une contrainte qui nous permettra d'annoter le champ author d'un objet book comme suit:
@Target({ElementType.TYPE, ElementType.TYPE_PARAMETER, ElementType.TYPE_USE, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = IsAcceptedValidator.class)
public @interface IsAccepted {
    String value();
    String message() default "{com.javatutorialshub.bookstore.core.IsAccepted.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
Comme vous l'auriez  remarqué, cette annotation fait référence au validator à utiliser, à l'aide de l'annotation @Constraint: IsAcceptedValidator. Nous allons donc écrire le code de ce validator qui se chargera tout juste de vérifier que le pays(country) de l'auteur correspond bien à la valeur acceptée, indiquée par la contrainte @IsAccepted.
  • Définir le code du validator de cette contrainte
public class IsAcceptedValidator implements ConstraintValidator<IsAccepted, Author> {
    private String value;

    @Override
    public void initialize(IsAccepted constraintAnnotation) {
        value = constraintAnnotation.value();
    }

    @Override
    public boolean isValid(Author author, ConstraintValidatorContext constraintValidatorContext) {
        return author != null && value != null && value.equals(author.country());
    }
}
Dès que la contrainte et le validator sont définies, la contrainte est prête à l'utilisation comme suit:
public record Book(
    ...

    @NotNull
    @Valid
    @IsAccepted("US")
    Author author,

    @NotNull
    @Valid
    User owner
) {
}
La documentation de Jakarta Bean Validation est accessible ici et ici
Le code source de ce tutoriel est présent sur github à cette adresse: book-store-service

Ne pas hésiter à commenter ou à poser une question ou à demander de l'aide autours de java et des technologies connexes. Nous nous ferons un plaisir de vous répondre.

Commentaires

Posts les plus consultés de ce blog

Découvrez comment SpringBoot facilite la parallélisation des tâches

Parfois il nous arrive d'avoir besoin de lancer des taches en parallèles afin d'accélérer le traitement d'une action. Ces taches s'exécuteront de manière indépendante dans des threads différents. Ces threads seront gérés par votre application principale. Ce processus de parallélisation (multithreading) est largement faciliter par springboot. Nous allons donc voir dans ce tutoriel, la parallélisation des taches à l'aide de springboot. 1.  Pourquoi paralléliser les taches avec springboot (multithreading) Comme nous l'avons déjà vu dans l'un de nos tutoriels , springboot vous simplifiera la mis en place et le prototypage de votre application en vous permettant de gérer aisément les taches parallèles, les pools de threads etc... Vous aurez la possibilité de déclarer via une méthode l'asynchornisme d'une tache, et soit attendre ou non la fin de toutes les taches parallèles. La méthode principale d'exécution de l'application sera l'orchestrateu...

Tout ce que vous devez savoir sur l'utilisation des collections en Java

Une collection est une structure de données qui permet à tout développeur java de stocker des données en mémoire et facilement accessible. Les collections peuvent dans certains cas être vues comme des tableaux de données évolués car ces dernières proposent d'innombrables méthodes d'accès afin de faciliter l'accès et la manipulation des données. 1. Qu'est qu'une collection Dans le langage Java, une collection est un objet qui implémente l'interface de base java.util.Collection. Une collection n'est pas une Map. Une collection est un objet dont la structure sou-jacente est un tableau à accès indexé. Il existe plusieurs catégories de collection. 2. Les List Il s'agit du type de collection le plus utilisé. Aucune contrainte sur les objets, aucune contrainte sur la taille de la liste. De plus les objets contenues dans une telle collection sont accessibles directement par un index. L'interface java.util.List est implémentée par plusieurs classes telles que...