Java / Spring Boot : Comment créer un validateur réutilisable de validation d'un champs par rapport à un autre ?

Je suis Tiburce SOTOHOU, Développeur Sénior Java / Angular, passionné par les Technologies Web, les Architectures Logicielles et la Cybersécurité. Je partage mes connaissances et mes découvertes sur les écosystèmes Angular et Java.
Dans ce projet nous avons utilisé Java 17 et Spring Boot 3.2.5.
Lorsque nous concevons nos applications, il est fréquent de rencontrer des scénarios de validation de données plus complexes.
Dans cet article, nous allons explorer une technique d'implémentation de validateur qui peut grandement simplifier notre travail dans certaines situations.
Prenons l'exemple où nous recevons en entrée d'un contrôleur un objet DTO (Data Transfer Object) à valider, comportant les champs suivants :
...
public class EmployeeDto {
private Integer id;
private String firstname;
private String lastname;
private EmployeeType employeeType;
private Double salary; // Ce champs est NonNull si employeeType est FULL_TIME_EMPLOYEE
private Double hourlyWage; // Ce champs est NonNull si employeeType est PART_TIME_EMPLOYEE
...
}
Supposons que nous ayons les contraintes de validation suivantes :
firstnameetlastnamesont non nulls.Le champs
salaryest non null si le champsemployeeTypecontient la valeurFULL_TIME_EMPLOYEELe champs
hourlyWageest non null si le champsemployeeTypepossède la valeurPART_TIME_EMPLOYEE
La première contrainte peut être aisément gérée à l'aide de l'annotation de validation @NotNull de Java.
En revanche, les contraintes 2 et 3 présentent une complexité plus grande à gérer avec les validateurs natifs fournis par le JDK. Ainsi, plusieurs autres options sont envisageables pour répondre à ces deux contraintes.
La méthode que nous privilégions dans cet article consiste à créer un validateur qui prend en paramètres trois éléments :
Le nom du champs de référence, ici c'est le champs
employeeTypeLa valeur visée pour ce champs, ici il s'agit de
FULL_TIME_EMPLOYEEouPART_TIME_EMPLOYEELe nom du champs cible à valider par rapport au champs de référence, ici
salaryouhourlyWage
Notre validateur est une annotation de contrainte que nous allons appeler @NotNullIf, qui se définit comme suit :
...
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Constraint(validatedBy = NotNullIfValidator.class)
public @interface NotNullIf {
String referenceFieldName(); // nom du champs de référence
String referenceFieldValue(); // valeur du champs de référence
String targetFieldName(); // nom du champs cible à valider
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Avec l'annotation @Constraint, nous définissons la classe de traitement pour notre annotation @NotNullIf comme suit : @Constraint(validatedBy = NotNullIfValidator.class). La classe, NotNullIfValidator, doit implémenter l'interface ConstraintValidator<A extends Annotation, T> du package jakarta.validation. Cette interface prend deux paramètres :
L'annotation à traiter, dans notre cas
NotNullIfLa classe des objets à valider, ici on va utiliser
Objectvu que notre annotation peut valider n'importe quel type d'objet
La classe NotNullIfValidator définit donc la logique de validation comme suit, le code est commenté pour la compréhension :
....
public class NotNullIfValidator implements ConstraintValidator<NotNullIf, Object> {
private String referenceFieldName;
private String referenceFieldValue;
private String targetFieldName;
@Override
public void initialize(NotNullIf annotation) { // on récupère les valeurs envoyées à l'annotation
ConstraintValidator.super.initialize(annotation);
referenceFieldName = annotation.referenceFieldName();
referenceFieldValue = annotation.referenceFieldValue();
targetFieldName = annotation.targetFieldName();
}
@Override
public boolean isValid(Object o, ConstraintValidatorContext ctx) { // Cette méthode contient la logique de validation
if(Objects.isNull(o)){ // On vérifie si l'objet à valider est null, dans ce cas on renvoie true
return true;
}
try {
/*
A l'aide de la classe BeanUtils de la librairie commons-beanutils, on récupère la valeur
actuelle du champs de référence dans l'objet à valider. BeanUtils utilise la réflexion
pour accéder aux champs spécifiés
*/
String currentReferenceFieldValue = BeanUtils.getProperty(o, referenceFieldName);
// Ensuite on récupère la valeur du champs cible
String targetFieldValue = BeanUtils.getProperty(o, targetFieldName);
/*
On vérifie si la valeur de référence envoyée dans l'annotation est la même que celle contenue
dans le champs de référence dans l'objet à valider. Si tel est le cas, et que la valeur du champs
cible de l'objet à valider est null, alors la contrainte n'est pas respectée et on retourne false
*/
if(referenceFieldValue.equals(currentReferenceFieldValue) && Objects.isNull(targetFieldValue)){
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate("Le champs ne peut pas être null").addPropertyNode(targetFieldName)
.addConstraintViolation();
return false;
}
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
throw new RuntimeException(e);
}
return true;
}
}
Pour appliquer les contraintes 1 et 2 mentionnées précédemment sur notre classe EmployeeDto, nous pouvons écrire le code suivant :
...
@NotNullIf(referenceFieldName = "employeeType", referenceFieldValue = "FULL_TIME_EMPLOYEE", targetFieldName = "salary")
public class EmployeeDto {
private Integer id;
@NotNull
private String firstname;
@NotNull
private String lastname;
private EmployeeType employeeType;
private Double salary; // Ce champs est NonNull si employeeType est FULL_TIME_EMPLOYEE
private Double hourlyWage; // Ce champs est NonNull si employeeType est PART_TIME_EMPLOYEE
}
@NotNullest utilisé pour s'assurer que les champsfirstnameetlastnamedeEmployeeDtone sont pas nuls.@NotNullIfest une annotation personnalisée pour valider que si le champemployeeTypepossèdeFULL_TIME_EMPLOYEEcomme valeur, alors le champsalaryne doit pas être nul.
Nous avons encore la troisième contrainte, similaire à la deuxième, que nous pouvons appliquer avec notre annotation @NotNullIf ; c'est là tout l'intérêt de notre code. Cependant, nous ne pouvons pas appliquer cette annotation deux fois de suite, comme dans l'exemple suivant, car cela ne fonctionnera pas :
@NotNullIf(referenceFieldName = "employeeType", referenceFieldValue = "FULL_TIME_EMPLOYEE", targetFieldName = "salary")
@NotNullIf(referenceFieldName = "employeeType", referenceFieldValue = "PART_TIME_EMPLOYEE", targetFieldName = "hourlyWage")
public class EmployeeDto {
...
}
Pour résoudre cela, le code de l'annotation a été modifié pour introduire une autre annotation capable de contenir plusieurs instances de celle-ci. Voici le code :
...
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Constraint(validatedBy = NotNullIfValidator.class)
public @interface NotNullIf {
...
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@interface List {
NotNullIf[] value(); // Peut contenir un tableau de @NotNullIf
}
}
Il est à noter que cette nouvelle annotation @NotNullIf.List aurait pu être déclarée dans un fichier séparé. Cependant, pour simplifier le code, elle a été intégrée ici. Voici le code complet de l'annotation :
...
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Constraint(validatedBy = NotNullIfValidator.class)
public @interface NotNullIf {
String referenceFieldName(); // nom du champs de référence
String referenceFieldValue(); // valeur du champs de référence
String targetFieldName(); // nom du champs cible à valider
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@interface List {
NotNullIf[] value(); // Peut contenir un tableau de @NotNullIf
}
}
Pour appliquer la troisième contrainte sur notre classe DTO, voici le code :
...
@NotNullIf.List({
@NotNullIf(referenceFieldName = "employeeType", referenceFieldValue = "FULL_TIME_EMPLOYEE", targetFieldName = "salary"),
@NotNullIf(referenceFieldName = "employeeType", referenceFieldValue = "PART_TIME_EMPLOYEE", targetFieldName = "hourlyWage")
})
public class EmployeeDto {
...
}
Nous avons utilisé l'annotation @NotNullIf.List à laquelle nous avons passé deux instances de l'annotation @NotNullIf, et c'est tout. Voici le code complet de EmployeeDto :
...
@NotNullIf.List({
@NotNullIf(referenceFieldName = "employeeType", referenceFieldValue = "FULL_TIME_EMPLOYEE", targetFieldName = "salary"),
@NotNullIf(referenceFieldName = "employeeType", referenceFieldValue = "PART_TIME_EMPLOYEE", targetFieldName = "hourlyWage")
})
public class EmployeeDto {
private Integer id;
@NotNull
private String firstname;
@NotNull
private String lastname;
private EmployeeType employeeType;
private Double salary; // Ce champs est NonNull si employeeType est FULL_TIME_EMPLOYEE
private Double hourlyWage; // Ce champs est NonNull si employeeType est PART_TIME_EMPLOYEE
...
}
Enfin, voici un exemple de l'utilisation de EmployeeDto dans un contrôleur :
...
@RestController
@RequestMapping("/employees")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
@PostMapping
public ResponseEntity<EmployeeDto> createEmployee(@Valid @RequestBody EmployeeDto employeeDto){
EmployeeDto employeeDtoToReturn = employeeService.save(employeeDto);
return new ResponseEntity<>(employeeDtoToReturn, HttpStatus.CREATED);
}
}
Cela montre comment les annotations de validation standard (@NotNull) et une annotation personnalisée (@NotNullIf) peuvent être utilisées ensemble pour valider un objet DTO de manière flexible et robuste dans une application Spring Boot.
Le code complet du projet intégrant toutes les couches de l'application est disponible ici sur Github. Vous pouvez le cloner et tester. Voici une capture de requête avec Postman :

Dans ce projet, nous avons utilisé Java 17 et Spring Boot 3.2.5



