Custom validation annotation in Spring

Although built-in validation support in Spring is largely sufficient for standard use cases, sooner or later every developer runs into a situation when the sets of validation annotations provided by JSR 303 or Hibernate Validator are not enough. In this post you will learn how to create a simple constraint annotation served by a custom validator with access to the Spring context of a Spring Boot application.

Advertisement

1. Setup of study case

For demonstration purpose, let’s consider a REST endpoint which will allow registration for new users of our application. Our goal of this tutorial is to validate login uniqueness provided by a client of the service.

@RestController
@RequestMapping("/users")
public class UserController {

    private UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @PostMapping
    public void register(@RequestBody @Valid User user) {
        userRepository.save(user);
    }

}

In a real application we would create some persistent storage for the collection of our users, but for simplicity of the example we’re going to use an in-memory storage directly in the repository class. In addition, we implement a method which based on the given login looks for a user in the registered user collection. We’re going to use it later on in our custom validator.

@Repository
class UserRepository {

    private List<User> registeredUsers = new LinkedList<>();

    void save(User user) {
        registeredUsers.add(user);
    }

    Optional<User> findByLogin(String login) {
        return registeredUsers.stream()
                .filter(user -> user.getLogin().equals(login))
                .findFirst();
    }

}

Finally, we declare the user data model which will act as the input for our service. The login field is annotated with a @UniqueLogin annotation which is going to be created in the next step.

public class User {

    @UniqueLogin
    private String login;
    private char[] password;

    private User() {
        // no-arg Jackson constructor
    }

    public User(String login, char[] password) {
        Objects.requireNonNull(login);
        Objects.requireNonNull(password);
        this.login = login;
        this.password = password;
    }
    // getters omitted
}

Note: By default Jackson uses reflection to set values of fields and requires no-argument constructor to be declared for a class. It can be made private to maintain the interface of your class unpolluted for public use and to keep Jackson working correctly.

2. Custom annotation

The declaration of @UniqueLogin may look quite complex at the first glance, but don’t get discouraged. The explanation can be found under the code listing.

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueLoginValidator.class)
public @interface UniqueLogin {
    String message() default "{com.dolszewski.blog.UniqueLogin.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Our custom annotation is itself annotated with three other annotations. If you have ever created a custom annotation, @Target and @Retention shouldn’t be new to you. If not, they are responsible for describing where the annotation can be applied and whether or not it should be available in the byte code so it might be read reflectively. In our case we allow @UniqueLogin to be used for class fields and methods because constrains can be declared on fields and setters.

More interesting is the @Constraint annotation, which actually marks our annotation for use as a validation constraint. In the validatedBy attribute, we should declare the class that will contain actual validation logic. We’re going to implement it in the next paragraph.

All attributes of our custom annotation are required by the @Constraint annotation. If you’re interested in their purpose, I refer you to the JavaDoc. The groups and payload attributes are left blank to fulfill the contract. In the message attribute we specified the key of the message that will be returned when the validator finds an error.

3. Error message

By default, error message keys are being searched in the file called ValidationMessages.properties that should be available on the application class path. If you don’t have one already in your app, it’s a good moment to create it and add the following key into its content.

com.dolszewski.blog.UniqueLogin.message=The given login is already in use

4. Validator implementation

Now it’s time for actual verification logic and the class that we declared in the validatedBy attribute of our custom constraint annotation.

class UniqueLoginValidator implements ConstraintValidator<UniqueLogin, String> {

    private UserRepository userRepository;

    public UniqueLoginValidator(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void initialize(UniqueLogin constraint) {
    }

    public boolean isValid(String login, ConstraintValidatorContext context) {
        return login != null && !userRepository.findByLogin(login).isPresent();
    }

}

The first thing that you should notice is the fact that the class isn’t annotated with any Spring component marker, yet it has a dependency on the UserRepository class which is managed by our Spring context. The Spring framework automatically detects all classes which implement the  ConstraintValidator interface, instantiate them, and wire all dependencies (Note this demo uses Spring 4.3, hence @Autowired annotation isn’t required by the constructor).

The ConstraintValidator interface expects two generic types. The first one is the corresponding constraint annotation. The second is the type of field which will be the target of this validator, since our login field is declared as String, we placed here this type.

The interface requires two methods to be implemented. The name of the initialize() method is self-explanatory, in our simple case we leave it. The isValid() method is the place where verification logic should be placed. Here, without any problem, we make use of the injected UserRepository instance.

5. Unit testing

Finally, we can test our solution. Because our validator relies upon the Spring Context, the integration test is mandatory.

@RunWith(SpringRunner.class)
@SpringBootTest
public class UserTest {

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private Validator validator;

    @Test
    public void shouldValidateDuplicatedLogin() throws Exception {
        // given
        String login = "daniel";
        User predefinedUser = new User(login, "pass".toCharArray()); 
        userRepository.save(predefinedUser);
        // when
        User newUser = new User(login, "asd".toCharArray());
        Set<ConstraintViolation<User>> violations = validator.validate(newUser);
        // then
        assertEquals(1, violations.size());
    }

}

The test is pretty straightforward. First, the registered user collection in the repository is populated with a predefined user. Next, we use the Validator abstraction injected from the Spring context to verify whether another user object with the same login as the predefined one is valid. As the result, we expect to receive one violation of constrains and that is what happens after test execution.

6. Summary

In this topic, we learned that in order to create a custom field verification, we need to create two elements. We start with a marker annotation similar to widely known constraint annotations like @NotNull or @Size that can be used in data model classes. The annotation doesn’t verify anything itself, hence a corresponding constraint validator class has to be implemented. The Spring framework takes care of the rest by registering the class and injecting all (if any) declared dependencies. As usual, the whole sample application can be found in the GitHub repository. You can also continue reading the validation series and learn about parametrized constraints. If you have any questions or doubts, please leave a comment or send me a private message.

Facebooktwittergoogle_plusredditlinkedinmail
Advertisement