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

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 :

  1. firstname et lastname sont non nulls.

  2. Le champs salary est non null si le champs employeeType contient la valeur FULL_TIME_EMPLOYEE

  3. Le champs hourlyWage est non null si le champs employeeType possède la valeur PART_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 ou PART_TIME_EMPLOYEE

  • Le nom du champs cible à valider par rapport au champs de référence, ici salary ou hourlyWage

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 champs firstname et lastname de EmployeeDto ne sont pas nuls.

  • @NotNullIf est une annotation personnalisée pour valider que si le champ employeeType possède FULL_TIME_EMPLOYEE comme valeur, alors le champ salary 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