Reputation: 549
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.
@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.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.Upvotes: 11
Views: 28842
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
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.
The class of public class PersonValidator implements Validator
keep as is, it's the best so far to encapsulate "validator 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);
}
}
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
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
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