Implementing custom validation logic for a Spring Boot endpoint using a combination of JSR-303 and Spring's Validator
Asked Answered
B

2

11

I'm trying to implement some custom validation logic for a Spring Boot endpoint using a combination of JSR-303 Bean Validation API and Spring's Validator.

Based on the Validator class diagram it appears to be possible to extend one of CustomValidatorBean, SpringValidatorAdapter or LocalValidatorFactoryBean to add some custom validation logic into an overridden method validate(Object target, Errors errors).

Validator class diagram.

However, if I create a validator extending any of these three classes and register it using @InitBinder its validate(Object target, Errors errors) method is never invoked and no validation is performed. If I remove @InitBinder then a default Spring validator performs the JSR-303 Bean Validation.

REST controller:

@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);
    }
}

Custom validator:

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);
    }

}

If my validator implements org.springframework.validation.Validator then its validate(Object target, Errors errors) method is called but JSR-303 Bean Validation is not performed prior to it. I can implement my custom JSR-303 validation similar to the way SpringValidatorAdapter implements its JSR-303 Bean Validation, but there has to be a way to extend it instead:

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

I have looked at using custom JSR-303 constraints to avoid using org.springframework.validation.Validator all together but there must be a way to make a custom validator work.

Spring validation documentation is not super clear on combining the two:

An application can also register additional Spring Validator instances per DataBinder instance, as described in Section 9.8.3, “Configuring a DataBinder”. This may be useful for plugging in validation logic without the use of annotations.

And then later on it touches on configuring multiple Validator instances:

A DataBinder can also be configured with multiple Validator instances via dataBinder.addValidators and dataBinder.replaceValidators. This is useful when combining globally configured Bean Validation with a Spring Validator configured locally on a DataBinder instance. See ???.

I'm using Spring Boot version 1.4.0.

Burtis answered 17/8, 2016 at 15:48 Comment(3)
I am not fully sure it's clear what your actual question is. Moreover, I guess it would help if you shared a complete example (e.g. the whole code). It's easier for us to help you then.Elkin
Have you read the documentation? Implement only your custom logic in a Validator and use addValidators instead of setValidator. This will invoke both the JSR-303 (default configured validator) and your custom one. However you are probably better of using JSR-303 validator/constraint instead of using 2 different mechanisms.Nonalcoholic
Thanks @M.Deinum - using addValidators instead of setValidator did the trick. I also agree that using JSR-303, @AssertTrue method based specifically for cross fields validation, is probably a cleaner solution. A code example is available at github.com/pavelfomin/spring-boot-rest-example/tree/feature/…. In the example, the middle name validation is performed via custom spring validator while last name validation is handled by the default jsr 303 validator.Burtis
B
6

Per @M.Deinum - using addValidators() instead of setValidator() did the trick. I also agree that using JSR-303, @AssertTrue method-based annotation specifically for cross fields validation, is probably a cleaner solution. A code example is available at https://github.com/pavelfomin/spring-boot-rest-example/tree/feature/custom-validator. In the example, the middle name validation is performed via custom spring validator while last name validation is handled by the default jsr 303 validator.

Burtis answered 22/8, 2016 at 15:21 Comment(1)
@AssertTrue method-based annotation specifically for cross fields validation, is probably a cleaner solution IMHO the cleaner solution is to write custom JSR-303 validator (example: #1973433).Afterbirth
P
8

This problem can be solved extending the LocalValidatorFactoryBean, you can override the validate method inside this class giving any behavior that you want.

In my case I need to use JSR-303 AND custom validators for same model in different methods in same Controller, normally is recommended to use @InitBinder, but it is not sufficient for my case because InitBinder make a bind between Model and Validator (if you use @RequestBody InitBinder is just for one model and one validator per Controller).

Controller

@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;
    }
}

Custom Validator

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());
     }
}

Class to be validate

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();
    }

}

Configure 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();
    }
}

Input to /test endpoint:

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

Output from /test endpoint:

{
    "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"
}

Input to /test2 endpoint:

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

Output to /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"
}

I hope this help.

Punkie answered 16/4, 2018 at 17:40 Comment(4)
Why I just cannot "register" my custom validator and make it work right away? Inst that the purpose of #supported method? Either this solution is a hack, or this API is silly.Darcee
Can you use multiple custom validators inside the same controller (one by each endpoint) using what you proposed? e.g.: endpointA -> validatorX. endpointB -> validatorY.Punkie
I don't want to use any bussiness validators on controller but on business methods. Manual validation is the only viable way for doing this. Otherwise I would have to use JSR validation annotations which is not enough or add custom annotation for every type that is validated - which is ridiculous. Its kind of strange that Spring's tools that are not part of MVC works only with MVC and not with generic beans (Talking about Spring's Validator interface)Darcee
Just to clear everything first... my code, above, solve the problem about "implement custom validation logic for a spring boot ENDPOINT (rest controller) using a combination of JSR and Spring Validator" where you can use different validators in the same controller, just passing the class of the validator. Probably your problem is different and my solution doesn't help you.Punkie
B
6

Per @M.Deinum - using addValidators() instead of setValidator() did the trick. I also agree that using JSR-303, @AssertTrue method-based annotation specifically for cross fields validation, is probably a cleaner solution. A code example is available at https://github.com/pavelfomin/spring-boot-rest-example/tree/feature/custom-validator. In the example, the middle name validation is performed via custom spring validator while last name validation is handled by the default jsr 303 validator.

Burtis answered 22/8, 2016 at 15:21 Comment(1)
@AssertTrue method-based annotation specifically for cross fields validation, is probably a cleaner solution IMHO the cleaner solution is to write custom JSR-303 validator (example: #1973433).Afterbirth

© 2022 - 2024 — McMap. All rights reserved.