Home > Back-end >  How to use custom validators in Spring
How to use custom validators in Spring

Time:04-09

I am building a Spring Boot application and trying to implement custom validation for some DTOs/Entities that I will be validating in the service layer. Based on the Spring documentation on this matter, I think one way to do this is to implement the org.springframework.validation.Validator interface.

As a minimal, complete, reproducible example, consider the following code:

Spring Initializr Bootstrapped Project

With the following code added in src/main/java/com.example.usingvalidation:

// Person.java

package com.example.usingvalidation;

public class Person {
    private String firstName;
    private String lastName;
    private int age;
    private String gender;

    public Person() {
    }

    public Person(String firstName, String lastName, int age, String gender) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
        this.gender = gender;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getGender() {
        return gender;
    }

    public void setGender(String gender) {
        this.gender = gender;
    }

    @Override
    public String toString() {
        return "Person{"  
                "firstName='"   firstName   '\''  
                ", lastName='"   lastName   '\''  
                ", age="   age  
                ", gender='"   gender   '\''  
                '}';
    }
}
// PersonValidator.java

package com.example.usingvalidation;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

@Component
public class PersonValidator implements Validator {

    private final Logger log = LoggerFactory.getLogger(this.getClass());

    @Override
    public boolean supports(Class<?> clazz) {
        log.info("supports called");
       return Person.class.equals(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        log.info("validate called");
        Person person = (Person) target;
        errors.reject("E00001", "This is the default error message, just to test.");
    }
}
// MyController.java
package com.example.usingvalidation;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.ConstraintViolation;
import java.util.Set;

@RestController
public class MyController {
    private final Logger log = LoggerFactory.getLogger(this.getClass());
    private final LocalValidatorFactoryBean validatorFactory;

    @Autowired
    public MyController(LocalValidatorFactoryBean validatorFactory) {
        this.validatorFactory = validatorFactory;
    }

    @GetMapping("/")
    public Person getPerson(@RequestBody Person person) {
        log.info("calling validate");
        Set<ConstraintViolation<Person>> errors =  validatorFactory.validate(person);
        log.info("called validate, result: {}", errors);
        return null;
    }
}
// UsingValidationApplication.java  nothing changed here

package com.example.usingvalidation;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MyController {
    private final Logger log = LoggerFactory.getLogger(this.getClass());
    private final LocalValidatorFactoryBean validatorFactory;

    @Autowired
    public MyController(LocalValidatorFactoryBean validatorFactory) {
        this.validatorFactory = validatorFactory;
    }

    @GetMapping("/")
    public Person getPerson(@RequestBody Person person) {
        log.info("calling validate");
        validatorFactory.validate(person);
        return null;
    }
}

If I hit the endpoint to trigger validation, nothing happens. I see the calling validate log message. But the errors object is empty. None of the log messages in PersonValidater are being logged, so clearly no calls are reaching there.

My question is: How do I register my Validator with Spring so that I can use the Validator?

I have gone through the docs multiple times and hundreds of SO questions (like java - Implementing custom validation logic for a spring boot endpoint using a combination of JSR-303 and Spring's Validator - Stack Overflow) but to no avail.

Additional Info

  • If there are any JSR-303 annotations like @NotNull then the current setup will pick up errors related to the JSR-303 validations. But that is not what I need, I need it to use my custom validator.
  • I saw other SO questions where InitBinder was used in the controller for registering the validator with Spring. But I don't want to do that as I plan to do these custom validations in the service layer.

CodePudding user response:

You should annotate your controller with the @Validated annotation. Don't forget to also add the spring-boot-starter-validation to your pom.

CodePudding user response:

The primary reason this isn't working for you is that you haven't registered your validator with the DataBinder.

Make a couple of changes to your controller. Instead of auto wiring the LocalValidatorFactoryBean, auto wire your validator(s) into the controller and register them with the DataBinder.

@Autowired
private PersonValidator personValidator;

@InitBinder
public void initBinder(WebDataBinder binder) {
  binder.addValidators(personValidator);
}

Your controller method will be simpler too as you no longer need to explicitly call the ValidatorFactory, Spring will invoke the validators automatically when you add the @Valid annotation to your method parameter. Add the BindingResult parameter to the method and all errors that come from the validators will be present in the BindingResult errors, this includes errors caused by the javax validations like, @Min, @Max, @NotNull, etc.

@GetMapping("/")
public Person getPerson(@RequestBody @Valid Person person, BindingResult bindingResult) {
   if (bindingResult.hasErrors()) {
      log.info(bindingResult.getAllErrors());
   }
   return null;
}

As you want to do this in the service layer, you're forced to write your own logic to handle this. Spring doesn't do any magic as far as calling the custom validations. This is intentional, the ingress into your application is through the controller, this is the one place where you have limited control over the data being input so if you want to validate, it should be handled here. Every mutation of the Person object downstream of the controller you have total control over. If you feel that you absolutely must validate in the service layer, then you're going to be writing this yourself and frankly, I wouldn't use an implementation of Spring's Validator for this. If you're deadset on doing this in the service layer, here's a way to pull it off.

Create an annotation to apply to your Person class

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;

@Documented
@Constraint(validatedBy = PersonValidator.class)
@Target({TYPE})
@Retention(RUNTIME)
public @interface ValidPerson {

  String message() default "This isn't correct";

  Class[] groups() default {};

  Class[] payload() default {};
}

Add the above annotation to your Person class.

@ValidPerson
public class Person {

Modify your PersonValidator to be a ConstraintValidator, I've thrown together an implementation that validates two of the fields on Person.

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import org.springframework.util.ObjectUtils;


public class PersonValidator implements ConstraintValidator<ValidPerson, Person> {

  @Override
  public void initialize(ValidPerson constraintAnnotation) {
    ConstraintValidator.super.initialize(constraintAnnotation);
  }

  @Override
  public boolean isValid(Person value, ConstraintValidatorContext context) {
     boolean isErrored = false;

     if (ObjectUtils.isEmpty(value.getLastName())) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate("lastName can't be empty").addConstraintViolation();
        isErrored = true;
     }
     if (value.getAge() < 0) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate("You're not old enough to be alive").addConstraintViolation();
        isErrored = true;
     }

    return !isErrored;
  }
}

In your service class, inject a Validator and call it in your method, this will invoke the ConstraintValidator that you have defined and added to your Person

import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class PersonService {

  @Autowired
  private Validator validator;

  public Person updatePerson(Person person) {
    Set<ConstraintViolation<Person>> validate = validator.validate(person);

    return person;
  }
}

You can do some fancy things with AOP to do this automatically the way that Spring does on the Controller side of things, but I'll leave that for you to discover.

  • Related