Field Matching Bean Validation Annotation Example
This tutorial demonstrates a Field Matching Bean Validation Annotation Example. When you are building forms you may come across a requirement to validate/compare if different fields inside a form are equal to another field in the same form like password and/or email fields. In this example we build a simple form where we have a password and a confirmPassword field. We need to make sure the user has entered the correct password twice before submitting the request.
Project Structure
Let’s start by looking at the project structure.
Maven Dependencies
We use Apache Maven to manage our project dependencies. Make sure the following dependencies reside on the class-path.
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.memorynotfound.spring.security</groupId> <artifactId>field-match</artifactId> <version>1.0.0-SNAPSHOT</version> <url>https://memorynotfound.com</url> <name>Spring Security - ${project.artifactId}</name> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.5.8.RELEASE</version> </parent> <properties> <java.version>1.8</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>commons-beanutils</groupId> <artifactId>commons-beanutils</artifactId> </dependency> <!-- bootstrap and jquery --> <dependency> <groupId>org.webjars</groupId> <artifactId>bootstrap</artifactId> <version>3.3.7</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>jquery</artifactId> <version>3.2.1</version> </dependency> <!-- testing --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
Field Matching Bean Validation Annotation
We start by creating the FieldMatch annotation. This is a class-level annotation where we can compare two fields for equality and pass in an optional message to display to the user if the constraint validation fails. We can also create a list of field matching annotations. This way we can validate field matching constraints multiple times.
package com.memorynotfound.spring.security.constraint; import javax.validation.Payload; import javax.validation.Constraint; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.ANNOTATION_TYPE; import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; @Target({TYPE, ANNOTATION_TYPE}) @Retention(RUNTIME) @Constraint(validatedBy = FieldMatchValidator.class) @Documented public @interface FieldMatch { String message() default "The fields must match"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; String first(); String second(); @Target({TYPE, ANNOTATION_TYPE}) @Retention(RUNTIME) @Documented @interface List { FieldMatch[] value(); } }
The FieldMatch annotation is validated by the FieldMatchValidator. This class reads the two fields and the message during the initialization. The isValid() method is invoked during bean validation. This method reads and compares the values of the two fields using commons-beanutils. When the first field doesn’t match the second field the validation fails and we add the error message to the conflicting property.
package com.memorynotfound.spring.security.constraint; import org.apache.commons.beanutils.BeanUtils; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; public class FieldMatchValidator implements ConstraintValidator<FieldMatch, Object> { private String firstFieldName; private String secondFieldName; private String message; @Override public void initialize(final FieldMatch constraintAnnotation) { firstFieldName = constraintAnnotation.first(); secondFieldName = constraintAnnotation.second(); message = constraintAnnotation.message(); } @Override public boolean isValid(final Object value, final ConstraintValidatorContext context) { boolean valid = true; try { final Object firstObj = BeanUtils.getProperty(value, firstFieldName); final Object secondObj = BeanUtils.getProperty(value, secondFieldName); valid = firstObj == null && secondObj == null || firstObj != null && firstObj.equals(secondObj); } catch (final Exception ignore) { // ignore } if (!valid){ context.buildConstraintViolationWithTemplate(message) .addPropertyNode(firstFieldName) .addConstraintViolation() .disableDefaultConstraintViolation(); } return valid; } }
Annotating Object With Bean Validator
Previously we created the @FieldMatch annotation which we are now using in the PasswordResetDto to validate if the password field matches the confirmPassword field. We optionally pass an message attribute which is displayed when the fields don’t match.
package com.memorynotfound.spring.security.web.dto; import com.memorynotfound.spring.security.constraint.FieldMatch; import org.hibernate.validator.constraints.NotEmpty; @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match") public class PasswordResetDto { @NotEmpty private String password; @NotEmpty private String confirmPassword; public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getConfirmPassword() { return confirmPassword; } public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; } }
Note: we can optionally create multiple field matching validators using the @FieldMatch.List annotation.
@FieldMatch.List({ @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match"), @FieldMatch(first = "email", second = "confirmEmail", message = "The email fields must match") })
Processing Form Controller
We use Spring MVC to process the PasswordResetDto form using the @Valid annotation the bean validation is triggered automatically. When the form has encountered some errors, we return the user to the view.
package com.memorynotfound.spring.security.web; import com.memorynotfound.spring.security.web.dto.PasswordResetDto; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import javax.validation.Valid; @Controller @RequestMapping("/reset-password") public class PasswordResetController { @ModelAttribute("passwordResetForm") public PasswordResetDto passwordReset() { return new PasswordResetDto(); } @GetMapping public String showPasswordReset(Model model) { return "reset-password"; } @PostMapping public String handlePasswordReset(@ModelAttribute("passwordResetForm") @Valid PasswordResetDto form, BindingResult result) { if (result.hasErrors()){ return "reset-password"; } // save/updaate form here return "redirect:/login?resetSuccess"; } }
Spring Boot
We use Spring Boot to start our application.
package com.memorynotfound.spring.security; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Run { public static void main(String[] args) { SpringApplication.run(Run.class, args); } }
Thymeleaf Reset Form Template
The reset-password.html thymeleaf template is located in the src/main/resources/templates folder. The template uses boostrap and jquery loaded from the org.webjars from Maven. It contains a simple form where the user has to enter two password fields. When these two fields match the form is validated.
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="utf-8"/> <meta http-equiv="X-UA-Compatible" content="IE=edge"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <link rel="stylesheet" type="text/css" th:href="@{/webjars/bootstrap/3.3.7/css/bootstrap.min.css}"/> <link rel="stylesheet" type="text/css" th:href="@{/css/main.css}"/> <title>Forgot Password</title> </head> <body> <div class="container"> <div class="row"> <div class="col-md-4 col-md-offset-4"> <div class="panel panel-default"> <div class="panel-body"> <div class="text-center"> <h3><i class="glyphicon glyphicon-lock" style="font-size:2em;"></i></h3> <h2 class="text-center">Reset password</h2> <div class="panel-body"> <div th:if="${error}"> <div class="alert alert-danger"> <span th:text="${error}"></span> </div> </div> <form th:action="@{/reset-password}" th:object="${passwordResetForm}" method="post"> <p class="error-message" th:if="${#fields.hasGlobalErrors()}" th:each="error : ${#fields.errors('global')}" th:text="${error}">Validation error</p> <input type="hidden" name="token" th:value="${token}"/> <div class="form-group" th:classappend="${#fields.hasErrors('password')}? 'has-error':''"> <div class="input-group"> <span class="input-group-addon"> <i class="glyphicon glyphicon-lock"></i> </span> <input id="password" class="form-control" placeholder="password" type="password" th:field="*{password}"/> </div> <p class="error-message" th:each="error: ${#fields.errors('password')}" th:text="${error}">Validation error</p> </div> <div class="form-group" th:classappend="${#fields.hasErrors('confirmPassword')}? 'has-error':''"> <div class="input-group"> <span class="input-group-addon"> <i class="glyphicon glyphicon-lock"></i> </span> <input id="confirmPassword" class="form-control" placeholder="Confirm password" type="password" th:field="*{confirmPassword}"/> </div> <p class="error-message" th:each="error: ${#fields.errors('confirmPassword')}" th:text="${error}">Validation error</p> </div> <div class="form-group"> <button type="submit" class="btn btn-block btn-success">Reset password</button> </div> </form> </div> </div> </div> </div> </div> </div> </div> <script type="text/javascript" th:src="@{/webjars/jquery/3.2.1/jquery.min.js/}"></script> <script type="text/javascript" th:src="@{/webjars/bootstrap/3.3.7/js/bootstrap.min.js}"></script> </body> </html>
Demo
Access http://localhost:8080/reset-password and fill in passwords that don’t match. You’ll receive the following error message.
Unit Testing
We used JUnit to write the FieldMatchConstraintValidatorTest Unit Test. This class tests the field matching annotation validator which we created earlier. First, we obtain a ValidatorFactory which we use to retrieve a Validator. Next we can create an instance of our PasswordResetDto form and pass it to the validator.
package com.memorynotfound.spring.security.test; import com.memorynotfound.spring.security.web.dto.PasswordResetDto; import org.junit.BeforeClass; import org.junit.Test; import javax.validation.ConstraintViolation; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.ValidatorFactory; import java.util.Set; import static org.junit.Assert.assertEquals; public class FieldMatchConstraintValidatorTest { private static Validator validator; @BeforeClass public static void setUp() { ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); validator = factory.getValidator(); } @Test public void testValidPasswords() { PasswordResetDto passwordReset = new PasswordResetDto(); passwordReset.setPassword("password"); passwordReset.setConfirmPassword("password"); Set<ConstraintViolation<PasswordResetDto>> constraintViolations = validator.validate(passwordReset); assertEquals(constraintViolations.size(), 0); } @Test public void testInvalidPassword() { PasswordResetDto passwordReset = new PasswordResetDto(); passwordReset.setPassword("password"); passwordReset.setConfirmPassword("invalid-password"); Set<ConstraintViolation<PasswordResetDto>> constraintViolations = validator.validate(passwordReset); assertEquals(constraintViolations.size(), 1); } }
Integration Testing
We use spring-test and MockMvc to write some integration tests. This test validates a valid and invalid form submission.
package com.memorynotfound.spring.security.test; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @AutoConfigureMockMvc @RunWith(SpringJUnit4ClassRunner.class) public class PasswordResetIT { @Autowired private MockMvc mockMvc; @Test public void submitPasswordResetSuccess() throws Exception { this.mockMvc .perform( post("/reset-password") .param("password", "password") .param("confirmPassword", "password") ) .andExpect(model().hasNoErrors()) .andExpect(redirectedUrl("/login?resetSuccess")) .andExpect(status().is3xxRedirection()); } @Test public void submitPasswordResetPasswordDoNotMatch() throws Exception { this.mockMvc .perform( post("/reset-password") .param("password", "password") .param("confirmPassword", "invalid-password") ) .andExpect(model().hasErrors()) .andExpect(model().attributeHasErrors("passwordResetForm")) .andExpect(status().isOk()); } }
Download
From:一号门
COMMENTS