Write custom validator for request body in Spring Boot
Photo by Chase Kinney on Unsplash
In this previous tutorial, we used predefined annotations provided by Hibernate validator to validate inputs in the request's body. Those annotations perform a specific check and do it very well.
But, often, we want to be more strict by applying a more complex validation. From our light reservation system, here are some validations we can perform to make the system better:
- The date of birth must be in the past, but someone can't be born one day before making a hotel reservation. We need to check if you have at least... let's say, 18 years old.
- The arrival date and departure date must be in the future, but the arrival date must be before the departure date. It doesn't make sense to say you arrive on the 4th of July and leave on the 1st of July.
Hibernate validator makes it possible to write a custom annotation that meets our needs. We will write a custom validator for each case above and see them in action.
Prerequisites
For this tutorial, you need the following tools installed on your computer:
- JDK 11 or higher - Download link
- Maven 3.5 or higher - Download link
- MySQL 8.0 - Download link
You can use Docker if you don't want to install MySQL on your computers. We will do that on the project setup.
Set up the project
We will start from the source code of the previous tutorial, available here:
git clone https://github.com/tericcabrel/blog-tutorials.git
cd blog-tutorials/springboot-validation
mvn install
docker run -it -e MYSQL_ROOT_PASSWORD=secretpswd -e MYSQL_DATABASE=hotels --name hotels-mysql -p 3307:3306 mysql:8.0
mvn spring-boot:run
You can use your favorite Java IDE to perform the Maven dependencies installation and launch the project. I use IntelliJ, which allows you to create a Spring Boot project.
Don't forget to check out the previous tutorial if you are stuck on how to consume the API.
Create custom validator
Creating a custom validator is achieved in two steps:
- We declare our custom annotation by providing information like the target, the class that holds the validation logic, the default error message, etc.
- We write the validation logic relative to the input's value to check.
Once done, all we need to do is annotate the property with the custom annotation and then provide the required arguments.
Custom validator for date of birth
When registering a user, the birth date is required and should be in the past, but 1 hour before the request is sent is still in the past, yet it is not valid in that case.
Let's create a package called constraints, then add an interface called BirthDate.java and add the code below:
package com.tericcabrel.hotel.constraints;
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.FIELD;
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;
import javax.validation.Payload;
@Constraint(validatedBy = BirthDateValidator.class)
@Target({ TYPE, FIELD, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Documented
public @interface BirthDate {
String message() default "{com.tericcabrel.hotel.constraints.BirthDate.message}";
Class <?> [] groups() default {};
Class <? extends Payload> [] payload() default {};
}
Now, create a file BirthDateValidator.java and add the code below:
package com.tericcabrel.hotel.constraints;
import java.util.Calendar;
import java.util.Date;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class BirthDateValidator implements ConstraintValidator<BirthDate, Date> {
@Override
public boolean isValid(final Date valueToValidate, final ConstraintValidatorContext context) {
Calendar dateInCalendar = Calendar.getInstance();
dateInCalendar.setTime(valueToValidate);
return Calendar.getInstance().get(Calendar.YEAR) - dateInCalendar.get(Calendar.YEAR) >= 18;
}
}
Update the file RegisterUserDto.java by adding the annotation @BirthDate on the property birthDate.
@NotNull(message = "The date of birth is required.")
@BirthDate(message = "The birth date must be greater or equal than 18")
@Past(message = "The date of birth must be in the past.")
private Date dateOfBirth;
Run the application and test it by sending a POST request to the route /user/register
.
As we can see, although the date is in the past, it fails if the difference isn't greater or equal to 18.
Custom validator for arrival and departure date
If I make a reservation for a hotel room, the date I arrive at the hotel is before the date I will leave. Writing the validation still requires 2 steps, but there is something different in this case.
Let's say we call our annotation @CompareDate. Since the arrivalDate and departureDate are two class attributes, if we add the annotation on the former, how do we tell our validator to the other attribute to compare with?
We will add the annotation on the class level (CreateReservationDto.java) with two arguments where the values are the attributes inside the class to compare. Here is what it will look like:
@CompareDate(before = "arrivalDate", after="departureDate", message = "The arrival date must be lower than the departure date")
public class CreateReservationDto {
}
Let's create an interface CompareDate.java then add the code below:
package com.tericcabrel.hotel.constraints;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Constraint(validatedBy = CompareDateValidator.class)
@Target({ TYPE, FIELD, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Documented
public @interface CompareDate {
String message() default "{com.tericcabrel.hotel.constraints.CompareDate.message}";
Class <?> [] groups() default {};
Class <? extends Payload> [] payload() default {};
String before();
String after();
}
Create the file CompareDateValidator.java the add the code below:
package com.tericcabrel.hotel.constraints;
import java.lang.reflect.Field;
import java.util.Date;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class CompareDateValidator implements ConstraintValidator <CompareDate, Object> {
private String beforeFieldName;
private String afterFieldName;
@Override
public void initialize(final CompareDate constraintAnnotation) {
beforeFieldName = constraintAnnotation.before();
afterFieldName = constraintAnnotation.after();
}
@Override
public boolean isValid(final Object value, final ConstraintValidatorContext context) {
try {
final Field beforeDateField = value.getClass().getDeclaredField(beforeFieldName);
beforeDateField.setAccessible(true);
final Field afterDateField = value.getClass().getDeclaredField(afterFieldName);
afterDateField.setAccessible(true);
final Date beforeDate = (Date) beforeDateField.get(value);
final Date afterDate = (Date) afterDateField.get(value);
return beforeDate == null && afterDate == null || beforeDate != null && beforeDate.before(afterDate);
} catch (final Exception e) {
e.printStackTrace();
return false;
}
}
}
The method initialize() is called after the instance of the validator is created. Here, we retrieve the value of the annotation arguments and set them for later use in the method isValid().
Inside the isValid() method, the first argument is the instance of the class CreateReservationDto. From this instance, we retrieve the value of the beforeFieldName (arrivalDate) and afterFieldName (departureDate) and finally compare them.
The feature that makes possible access to the properties of an object at the runtime is called Reflection. Check out this link to learn more.
Run the application and test it by sending a POST request to the route /reservations
.
The validation fails, but we don't have an explicit error. Why?
The error throws an exception of type MethodArgumentNotValidException, which is already caught in the file GlobalExceptionHandler.java. If we pay attention to how the errors are retrieved, we will find the issue.
List<String> errors = ex.getBindingResult().getFieldErrors()...
We retrieve errors on the field, aka attribute of the object yet, our validation is done on the class level. This is why an empty array is returned. We will update to use the method getAllErrors() instead.
Run the application and test.
Tadaaa! We have an explicit message now.
Conclusion
The Hibernate Validator helps you validate the input of the request sent to your API but also allows you to define a custom validator for complex cases specific to your business domain.
With these features, we have a strong way to validate the input of the request body and make our backend more reliable.
You can find the source code on the GitHub repository.
Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.