Java / Spring Boot : Comment créer un validateur réutilisable de validation d'un champs par rapport à un autre ?
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 :
firstname
etlastname
sont non nulls.Le champs
salary
est non null si le champsemployeeType
contient la valeurFULL_TIME_EMPLOYEE
Le champs
hourlyWage
est non null si le champsemployeeType
possè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
employeeType
La valeur visée pour ce champs, ici il s'agit de
FULL_TIME_EMPLOYEE
ouPART_TIME_EMPLOYEE
Le nom du champs cible à valider par rapport au champs de référence, ici
salary
ouhourlyWage
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
NotNullIf
La classe des objets à valider, ici on va utiliser
Object
vu 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
}
@NotNull
est utilisé pour s'assurer que les champsfirstname
etlastname
deEmployeeDto
ne sont pas nuls.@NotNullIf
est une annotation personnalisée pour valider que si le champemployeeType
possèdeFULL_TIME_EMPLOYEE
comme valeur, alors le champsalary
ne 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