strider0160
strider0160

Reputation: 549

How to use custom validators in Spring

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

Upvotes: 11

Views: 28842

Answers (4)

Lakshman Kumar
Lakshman Kumar

Reputation: 44

If you want to create your own validator in form of annotation. Below info will help you.

I have created my own validator in my project to validate the tags given by the user. Which validates the tags for spaces and #. This @Hashtag annotation in below code is my custom annotation.

First of all you need decide the annotation name and declare an interface like below: (This interface will be implemented by a java class "HashtagConstraintValidator" where you will provide your logic of validation)

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = HashtagConstraintValidator.class)//says the logic of validation is in which class
@Target({ElementType.FIELD})//decides what level this annotation would work like on only fileds/ class level or both fields and class level.
@Retention(RetentionPolicy.RUNTIME)//decides up to which time this annotation should be valid like till compile time or runtime
public @interface Hashtag {

//This is kind of a method which returns # as a value to its caller and we use this value in our implementation class to build the logic 
    public String value() default "#";
//This is kind of a method which returns , as a value to its caller and we use in our implementation class to build the logic
    public String separator() default ",";

//This message will be shown on UI when the user input fails the validation
    public String message() default "[Tags should not start with #],[divide with , (comma)],[Do not give .(periods)]," +
            "[Should not have spaces]";

    //Groups-can group related constraints
    public Class<?>[] groups() default {};

    //Payload-Extra info about errors
    public Class<? extends Payload>[] payload() default {};
}

Implementation class

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class HashtagConstraintValidator implements ConstraintValidator<Hashtag, String> {

    private String tagPrefix;//value will be #
    private String tagSuffix;//value will be ,

//Both initialize() and isValid() are mandatory

    @Override
    public void initialize(Hashtag hashtag) {
        tagPrefix = hashtag.value();
        tagSuffix = hashtag.separator();
    }

//Your validation logic
    @Override
    public boolean isValid(String theTags, ConstraintValidatorContext constraintValidatorContext) {
        boolean result = false;

        if(theTags == null) {
            return true;
        } else if(theTags.contains("#") || theTags.contains(" ") || theTags.contains(".")) {
            return false;
        }

        String[] tags = theTags.split(",");

        for(String tag : tags){
            if(tag.trim().equals("") || tag.trim().equals("#")){
                return false;
            }
        }

        if(tags.length ==1){
            result = true;
        } else {
            result = theTags.contains(tagSuffix) ? true : false;
        }

        return result;
    }
}

Now the @Hashtag can be applied on the fields only because I gave @Target({ElementType.FIELD})

@Hashtag
    private String tags;

you need to use @Valid and BindingResult in your controller to catch the validation error and send the error back to user.

@PostMapping("/publish")
    public String saveTags(@Valid @ModelAttribute("tags") String tags, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "postTagsPage";
        }

If you are using thymeleaf as your template. Below code will display the error to the user on UI

<p th:if="${#fields.hasErrors('tags')}" th:errorclass="error" th:errors="*{tags}"/>

Upvotes: 0

Johan
Johan

Reputation: 425

After searching with the spark of luck, got enlightenment from this documentation https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/validation.html#validation-binder

I hate writing custom annotation, so here we come.

Validator Class

The class of public class PersonValidator implements Validator keep as is, it's the best so far to encapsulate "validator class".

AOP Class

This AOP class, "magically" intercept before method "save" executed.

@Aspect
@Component
public class RoAspect {

@Autowired
private PersonValidator validator;

/**
 * Validation before save
 *
 * @param joinPoint join point information
 * @param entity    passed entity
 */
@Before("execution(* go.to.your.service.PersonService+.save(*)) && args(entity)")
public void beforeSave(JoinPoint joinPoint, Person entity) throws MethodArgumentNotValidException {

    DataBinder dataBinder = new DataBinder(entity);
    dataBinder.setValidator(validator);
    dataBinder.validate();

    BindingResult bindingResult = dataBinder.getBindingResult();

    if(bindingResult.hasErrors()){
        throw new MethodArgumentNotValidException(null, bindingResult);
    }
    }

Exception Handler

Handle the exception thrown by "AOP class"

@RestControllerAdvice
public class ControllerExceptionHandler {

@ExceptionHandler({MethodArgumentNotValidException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, List<String>> handleValidationError(MethodArgumentNotValidException exception) {

    Map<String, List<String>> errorMap = new HashMap<>();
    List<String> errors = new ArrayList<>();
    if (exception.hasErrors()) {
        for (ObjectError error : exception.getAllErrors()) {
            errors.add(error.getDefaultMessage());
        }
        errorMap.put("errors", errors);
    }
    return errorMap;
}
}

That's it. May be you're wondering the keyword of "@RestControllerAdvice". Yes it's designed for controller only (may be!, correct if i'm wrong).

But, don't worry it can be handled also using native java "try catch". Example :

    try {
        personService.save(new Person());
    } catch (Exception e) {
        // exception thrown by "AOP class" can be catch here
    }

Upvotes: 0

lane.maxwell
lane.maxwell

Reputation: 5862

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.

Upvotes: 14

Romain VDK
Romain VDK

Reputation: 1896

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

Upvotes: 2

Related Questions