Custom Spring Boot validation annotations

Creating a valid password check and a password confirmation validator using custom Spring Boot validation annotations.

Alex Lanz
Alex Lanz20th September 2022

Validating data is a very important and critical task and should be implemented very well in every application. Spring Boot provides already nice and easy to use annotations, that can be used for all kinds of basic validations. However, when you work with these validations, then you will most likely reach a point, where you cannot find an appropriate annotation that provides out of the box checks that you need for some special edge cases.

Therefore, in this post, we will show you how you can implement your own custom validation annotations and how to assign the error messages to the right fields. As an example we will implement two validators. The first validation will check if a password follows all requested rules. The second validation will check if the password and its confirmation field are the same.

Step 1 - The Annotation Interface

The starting point when creating a new custom Spring Boot validation annotation is the interface. It defines how the annotation is called, some parameters that can be defined when using it and the validator class, that performs the actual check.

1package it.aboutbits.support.validation;
2
3import javax.validation.Constraint;
4import javax.validation.Payload;
5import java.lang.annotation.Documented;
6import java.lang.annotation.Retention;
7import java.lang.annotation.Target;
8
9import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
10import static java.lang.annotation.ElementType.FIELD;
11import static java.lang.annotation.RetentionPolicy.RUNTIME;
12
13@Documented
14@Constraint(validatedBy = ValidPasswordValidator.class)
15@Target({FIELD, ANNOTATION_TYPE})
16@Retention(RUNTIME)
17public @interface ValidPassword {
18    String message() default "The password does not comply with the rules.";
19
20    Class<?>[] groups() default {};
21
22    Class<? extends Payload>[] payload() default {};
23}
24

The name of the interface defines also the name of the annotation. Here we call the annotation ValidPassword.

Next, the field message defines a parameter, that can be used by the user to pass some information to the validator. This will be shown in more detail later on.

The @Constraint annotation above the class defines the actual validator implementation. The ValidPasswordValidator class will be used for the checks.

Step 2 - The Validator Class

As a next step, we have to implement the already previously referenced validator class, which is responsible for checking the data.

1package it.aboutbits.support.validation;
2
3import org.apache.commons.lang3.StringUtils;
4import org.passay.LengthRule;
5import org.passay.PasswordData;
6import org.passay.PasswordValidator;
7import org.passay.WhitespaceRule;
8
9import javax.validation.ConstraintValidator;
10import javax.validation.ConstraintValidatorContext;
11import java.util.List;
12
13public class ValidPasswordValidator implements ConstraintValidator<ValidPassword, String> {
14
15    private String message;
16
17    @Override
18    public void initialize(final ValidPassword constraintAnnotation) {
19        this.message = constraintAnnotation.message();
20    }
21
22    @Override
23    public boolean isValid(final String password, final ConstraintValidatorContext context) {
24
25        PasswordValidator validator = new PasswordValidator(List.of(
26                // at least 8 characters
27                new LengthRule(8, 50)
28        ));
29
30        boolean isValid = StringUtils.isNotBlank(password) && validator.validate(new PasswordData(password)).isValid();
31
32        if (!isValid) {
33            context.disableDefaultConstraintViolation();
34            context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
35        }
36
37        return isValid;
38    }
39}
40

As you can see from the example, the field message is here now available to the validator. The validator can read it out in the initialization phase (initialize) and access the variable later on in the validation phase (isValid).

The input value of the field from the object under validation is available as a first parameter passed to the validation method. You can now implement here all checks you want.

The method should return true for valid data and false for invalid data.

Sometimes the default ConstraintValidationException doesn't communicate the error to the user very well. Often you want also provide to the user some hints about what is wrong. Here Spring Boot provides also a context object, that is passed to the isValid method. Using this context object, we have the possibility to pass some messages to the user by placing a message for a specific field/variable.

Step 3 - Checking the Data

Last, we have to add the annotation to our validated object. In this example we validate the request body of a typical password change operation.

1package it.aboutbits.rest.user.request;
2
3import it.aboutbits.support.validation.ConfirmedField;
4import it.aboutbits.support.validation.ValidPassword;
5import lombok.Data;
6
7@Data
8@ConfirmedField(originalField = "newPassword", confirmationField = "newPasswordConfirmation")
9public class ChangePasswordBody {
10    private String oldPassword;
11    @ValidPassword
12    private String newPassword;
13    private String newPasswordConfirmation;
14}
15

As we can see in the example, the annotation is placed above the variable and so the validation will be executed for this field.

Inside the controller, we can then reference this request object.

1package it.aboutbits.rest.user;
2
3import it.aboutbits.rest.user.request.ChangePasswordBody;
4import io.swagger.v3.oas.annotations.Operation;
5import io.swagger.v3.oas.annotations.tags.Tag;
6import lombok.RequiredArgsConstructor;
7import org.springframework.validation.annotation.Validated;
8import org.springframework.web.bind.annotation.PutMapping;
9import org.springframework.web.bind.annotation.RequestBody;
10import org.springframework.web.bind.annotation.RequestMapping;
11import org.springframework.web.bind.annotation.RestController;
12
13import javax.validation.Valid;
14
15@RestController
16@RequestMapping("/app/v1/profile")
17@Tag(name = "Profile API")
18@RequiredArgsConstructor
19@Validated
20public class ProfileController {
21    @PutMapping("/password")
22    @Operation(summary = "Change password of logged in user.")
23    public void changePassword(final @Valid @RequestBody ChangePasswordBody body) {
24        ...
25    }
26}
27
28

Here two things are important. First, the annotation @Validated has to be added to the controller. And second, the @Valid annotation has to be put in front of the request. Like this, Spring Boot picks up all the data and validates it before passing it on to the actual code of the method. If there is an error in the data, then the actual code will never be reached.

Validating two fields together

The approach described above highlights a check that requires only one field for the analysis. But sometimes validations require checking two or more fields together.

To also show how you could implement such a custom validation for multiple fields, we will pick up the ConfirmedField annotation already applied above and show how to create this validator.

Therefore, also here we first have to create the interface:

1package it.aboutbits.support.validation;
2
3import javax.validation.Constraint;
4import javax.validation.Payload;
5import java.lang.annotation.Documented;
6import java.lang.annotation.ElementType;
7import java.lang.annotation.Retention;
8import java.lang.annotation.RetentionPolicy;
9import java.lang.annotation.Target;
10
11import static java.lang.annotation.RetentionPolicy.RUNTIME;
12
13@Target(ElementType.TYPE)
14@Retention(RUNTIME)
15@Constraint(validatedBy = ConfirmedFieldValidator.class)
16@Documented
17public @interface ConfirmedField {
18
19    String message() default "Doesn't match the original";
20
21    String originalField();
22
23    String confirmationField();
24
25    Class<?>[] groups() default {};
26
27    Class<? extends Payload>[] payload() default {};
28
29    @Target({ElementType.TYPE})
30    @Retention(RetentionPolicy.RUNTIME)
31    @interface List {
32        ConfirmedField[] value();
33    }
34}
35

Here we can see that in addition to the message parameter also other parameters are required. These are called originalField and confirmationField. In the example above, where the validator is attached to the request, we can see how these two parameters are defined and passed to the validation.

Another important point to note is, that this validator is attached to the whole request class and not only to a single variable. This allows us to access not only the value of one single field, but all the variables of the class. This way we can compare if the two fields are the equal. However, we have to tell the validator which fields we want to compare and that's why we have to define the originalField and the confirmationField.

1package it.aboutbits.support.validation;
2
3import org.springframework.beans.BeanWrapperImpl;
4
5import javax.validation.ConstraintValidator;
6import javax.validation.ConstraintValidatorContext;
7
8public class ConfirmedFieldValidator implements ConstraintValidator<ConfirmedField, Object> {
9
10    private String originalField;
11    private String confirmationField;
12    private String message;
13
14    public void initialize(final ConfirmedField constraintAnnotation) {
15        this.originalField = constraintAnnotation.originalField();
16        this.confirmationField = constraintAnnotation.confirmationField();
17        this.message = constraintAnnotation.message();
18    }
19
20    public boolean isValid(final Object value, final ConstraintValidatorContext context) {
21        Object fieldValue = new BeanWrapperImpl(value).getPropertyValue(originalField);
22        Object fieldMatchValue = new BeanWrapperImpl(value).getPropertyValue(confirmationField);
23
24        boolean isValid = fieldValue != null && fieldValue.equals(fieldMatchValue);
25
26        if (!isValid) {
27            context.disableDefaultConstraintViolation();
28            context
29                    .buildConstraintViolationWithTemplate(message)
30                    .addPropertyNode(confirmationField)
31                    .addConstraintViolation();
32        }
33
34        return isValid;
35    }
36}
37

As you can see from the example, the fields originalField, confirmationField and message are now available to the validator. The validator can read them out in the initialization phase (initialize) and check them later on in the validation phase (isValid).

The input value of the field from the object under validation is available as a first parameter passed to the validation method. Using this object, we can get the values of the inputs of the two fields and check if they are equal.

Also adding the message is a bit different, because we have to tell Spring Boot to add the message to the confirmation field. This way the user will see the error message nearby the confirmation field.

Conclusion

Custom validations allow you to extend the existing validations provided by Spring Boot and offer a nice and easy to use approach to validate the data for custom edge cases in the same way.

In addition, the context of the current object can be used to pass custom error messages to the right fields to give the users more context about the wrong input.

How can we help you?
We are happy to assist you.
Contact us now