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 it happens 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 it is impossible for some born 1h before the registration to make a 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.

Setup 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. Don't forget to check the previous part if you are stuck on how to consume the API.

Create custom validator

Create 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, default errors message, etc.
  2. We write the validation logic to be used on the input value.

Once done, all we need to do is annotate the property with the custom annotation 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 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 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.

As we can see, even if 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 I 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 arrivalDate and departureDate are two properties if we add the annotation on the former, how do we tell our validator to the property to compare with?

We will add the annotation on the class level (CreateReservationDto.java), with two arguments where the values are the properties name 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();
}

Note the two properties before() and after() that represent additional arguments of the annotation.

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 call Reflection. Check this link to learn more.

Run the application and test it.

The validation fails, but we don't have an explicit error. Why 🤔

The error throws an exception of the type MethodArgumentNotValidException who is watched in 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 properties 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

It is the end of this tutorial, and now, 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.

I hope you found it interesting and see you at the next tutorial 😉.