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 |
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
) {
}
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
Enregistrer un commentaire