web-dev-qa-db-fra.com

Implémentation d'une logique de validation personnalisée pour un point de terminaison Spring à l'aide d'une combinaison de JSR-303 et du validateur Spring

J'essaie d'implémenter une logique de validation personnalisée pour un point de terminaison Spring Boot en combinant JSR-303 Bean Validation API et Spring's Validator.

Sur la base du diagramme de classe Validator, il semble possible d'étendre l'une des options CustomValidatorBean, SpringValidatorAdapter ou LocalValidatorFactoryBean à l'ajout d'une logique de validation personnalisée à une méthode surchargée validate(Object target, Errors errors).

 Validator class diagram .

Cependant, si je crée un validateur qui étend l'une de ces trois classes et que je l'enregistre à l'aide de @InitBinder, sa méthode validate(Object target, Errors errors) n'est jamais appelée et aucune validation n'est effectuée. Si je supprime @InitBinder, un validateur de ressort par défaut exécute le JSR-303 Bean Validation.

Contrôleur de repos:

@RestController
public class PersonEndpoint {

    @InitBinder("person")
    protected void initBinder(WebDataBinder binder) {
        binder.setValidator(new PersonValidator());
    }

    @RequestMapping(path = "/person", method = RequestMethod.PUT)
    public ResponseEntity<Person> add(@Valid @RequestBody Person person) {

        person = personService.save(person);
        return ResponseEntity.ok().body(person);
    }
}

Validateur personnalisé:

public class PersonValidator extends CustomValidatorBean {

    @Override
    public boolean supports(Class<?> clazz) {
        return Person.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        super.validate(target, errors);
        System.out.println("PersonValidator.validate() target="+ target +" errors="+ errors);
    }

}

Si mon validateur implémente org.springframework.validation.Validator, sa méthode validate(Object target, Errors errors) est appelée mais JSR-303 Bean Validation n'est pas exécuté avant. Je peux implémenter ma validation JSR-303 personnalisée de la même manière que SpringValidatorAdapter implémente son JSR-303 Bean Validation mais il doit y avoir un moyen de l'étendre à la place:

    @Override
    public void validate(Object target, Errors errors) {
        if (this.targetValidator != null) {
            processConstraintViolations(this.targetValidator.validate(target), errors);
        }
    }

J'ai envisagé d'utiliser des contraintes personnalisées JSR-303 pour éviter d'utiliser org.springframework.validation.Validator dans son ensemble, mais il doit exister un moyen de faire fonctionner un validateur personnalisé.

Spring documentation de validation n’est pas très clair sur la combinaison des deux:

Une application peut également enregistrer d'autres instances Spring Validator par instance DataBinder, comme décrit à la Section 9.8.3, «Configuration d'un DataBinder». Cela peut être utile pour brancher la logique de validation sans utiliser d'annotations.

Et plus tard, il aborde la configuration de plusieurs instances de Validator 

Un DataBinder peut également être configuré avec plusieurs instances de Validator via dataBinder.addValidators et dataBinder.replaceValidators. Ceci est utile lors de la combinaison de la validation de bean configurée globalement avec un validateur Spring configuré localement sur une instance de DataBinder. Voir ???.

J'utilise Spring Boot 1.4.0.

6
pavel

Per @ M.Deinum - utiliser addValidators () à la place de setValidator () a tout gâché. Je conviens également que l’utilisation de JSR-303, annotation basée sur la méthode @ AssertTrue spécifiquement pour la validation de champs croisés, est probablement une solution plus propre. Un exemple de code est disponible sur https://github.com/pavelfomin/spring-boot-rest-example/tree/feature/custom-validator . Dans l'exemple, la validation du deuxième prénom est effectuée via un validateur de ressort personnalisé, tandis que la validation du nom de famille est gérée par le validateur JSR 303 par défaut.

4
pavel

Ce problème peut être résolu en étendant LocalValidatorFactoryBean, vous pouvez remplacer la méthode validate à l'intérieur de cette classe en donnant le comportement que vous souhaitez.

Dans mon cas, j’ai besoin d’utiliser JSR-303 ET des validateurs personnalisés pour le même modèle dans différentes méthodes du même contrôleur. Il est normalement recommandé d’utiliser @InitBinder, mais cela ne suffit pas dans mon cas, car InitBinder établit un lien entre Model et Validator (si vous utilisez @RequestBody InitBinder est juste pour un modèle et un validateur par contrôleur).

Manette

@RestController
public class LoginController {

    @PostMapping("/test")
    public Test test(@Validated(TestValidator.class) @RequestBody Test test) {
        return test;
    }

    @PostMapping("/test2")
    public Test test2(@Validated @RequestBody Test test) {
        return test;
    }
}

Validateur personnalisé

public class TestValidator implements org.springframework.validation.Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Test.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Test test = (Test) target;
        errors.rejectValue("field3", "weird");
        System.out.println(test.getField1());
        System.out.println(test.getField2());
        System.out.println(test.getField3());
     }
}

Classe à valider

public class Test {

    @Size(min = 3)
    private String field2;

    @NotNull
    @NotEmpty
    private String field1;

    @NotNull
    @Past
    private LocalDateTime field3;

    //...
    //getter/setter
    //...
}

CustomLocalValidatorFactoryBean

public class CustomLocalValidatorFactoryBean extends LocalValidatorFactoryBean {

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public void validate(@Nullable Object target, Errors errors, @Nullable Object... validationHints) {
        Set<Validator> concreteValidators = new LinkedHashSet<>();
        Set<Class<?>> interfaceGroups = new LinkedHashSet<>();
        extractConcreteValidatorsAndInterfaceGroups(concreteValidators, interfaceGroups, validationHints);
        proccessConcreteValidators(target, errors, concreteValidators);
        processConstraintViolations(super.validate(target, interfaceGroups.toArray(new Class<?>[interfaceGroups.size()])), errors);
    }

    private void proccessConcreteValidators(Object target, Errors errors, Set<Validator> concreteValidators) {
        for (Validator validator : concreteValidators) {
            validator.validate(target, errors);
        }
    }

    private void extractConcreteValidatorsAndInterfaceGroups(Set<Validator> concreteValidators, Set<Class<?>> groups, Object... validationHints) {
        if (validationHints != null) {
            for (Object hint : validationHints) {
                if (hint instanceof Class) {
                    if (((Class<?>) hint).isInterface()) {
                        groups.add((Class<?>) hint);
                    } else {
                        Optional<Validator> validatorOptional = getValidatorFromGenericClass(hint);
                        if (validatorOptional.isPresent()) {
                            concreteValidators.add(validatorOptional.get());
                        }
                    }
                }
            }
        }
    }

    @SuppressWarnings("unchecked")
    private Optional<Validator> getValidatorFromGenericClass(Object hint) {
        try {
            Class<Validator> clazz = (Class<Validator>) Class.forName(((Class<?>) hint).getName());
            return Optional.of(clazz.newInstance());
        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) {
            logger.info("There is a problem with the class that you passed to "
                    + " @Validated annotation in the controller, we tried to "
                    + " cast to org.springframework.validation.Validator and we cant do this");
        }
        return Optional.empty();
    }

}

Configurer l'application

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean
    public javax.validation.Validator localValidatorFactoryBean() {
        return new CustomLocalValidatorFactoryBean();
    }
}

Entrée sur /test endpoint:

{
    "field1": "",
    "field2": "aaaa",
    "field3": "2018-04-15T15:10:24"
}

Sortie du point final /test:

{
    "timestamp": "2018-04-16T17:34:28.532+0000",
    "status": 400,
    "error": "Bad Request",
    "errors": [
        {
            "codes": [
                "weird.test.field3",
                "weird.field3",
                "weird.Java.time.LocalDateTime",
                "weird"
            ],
            "arguments": null,
            "defaultMessage": null,
            "objectName": "test",
            "field": "field3",
            "rejectedValue": "2018-04-15T15:10:24",
            "bindingFailure": false,
            "code": "weird"
        },
        {
            "codes": [
                "NotEmpty.test.field1",
                "NotEmpty.field1",
                "NotEmpty.Java.lang.String",
                "NotEmpty"
            ],
            "arguments": [
                {
                    "codes": [
                        "test.field1",
                        "field1"
                    ],
                    "arguments": null,
                    "defaultMessage": "field1",
                    "code": "field1"
                }
            ],
            "defaultMessage": "Não pode estar vazio",
            "objectName": "test",
            "field": "field1",
            "rejectedValue": "",
            "bindingFailure": false,
            "code": "NotEmpty"
        }
    ],
    "message": "Validation failed for object='test'. Error count: 2",
    "path": "/user/test"
}

Entrée sur /test2 endpoint:

{
    "field1": "",
    "field2": "aaaa",
    "field3": "2018-04-15T15:10:24"
}

Sortie sur /test2 endpoint:

{
    "timestamp": "2018-04-16T17:37:30.889+0000",
    "status": 400,
    "error": "Bad Request",
    "errors": [
        {
            "codes": [
                "NotEmpty.test.field1",
                "NotEmpty.field1",
                "NotEmpty.Java.lang.String",
                "NotEmpty"
            ],
            "arguments": [
                {
                    "codes": [
                        "test.field1",
                        "field1"
                    ],
                    "arguments": null,
                    "defaultMessage": "field1",
                    "code": "field1"
                }
            ],
            "defaultMessage": "Não pode estar vazio",
            "objectName": "test",
            "field": "field1",
            "rejectedValue": "",
            "bindingFailure": false,
            "code": "NotEmpty"
        }
    ],
    "message": "Validation failed for object='test'. Error count: 1",
    "path": "/user/test2"
}

J'espère que cette aide.

1
Marco Blos