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:

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:

  1. We declare our custom annotation by providing information like the target, the class that holds the validation logic, the default error message, etc.
  2. 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.

The API returns a validation error when the birth date is invalid.

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

💡
The two properties before() and after() represent the additional annotation arguments.

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 API responds with an error 400, but there are no message details

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.

The API responds with an error message when the date comparison is invalid.

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.