Handle Validation Requirement By Hibernate Validator
This Blog Post will show some common validation requirements and handle them by Hibernate Validator, including Cross Field Validation, Conditional Validation, i18n And Error Field Name. The example code can find from https://github.com/kenwu565657/validator-spring-boot-starter-github-repo.
Part 0: Example POJO Class
This Blog Post will use the same POJO Class for demo. Let's quickly go through the fields In this class.
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2public class ExampleForm { 3 private String firstname; 4 5 private String lastname; 6 7 private CollectionMethod collectionMethod; 8 9 private String mailReceiverFullName; 10 11 private Address address; 12 13 public enum CollectionMethod { IN_PERSON, MAIL } 14 15 public static class Address { 16 private String flat; 17 private String street; 18 private String city; 19 } 20 21 ... 22}
Part 1: Cross Field Validation
Let's say there is a validation requirement that the firstname and lastname must in same language. Assume the input will be English or Chinese only. Then, we need to do a cross field validation on firstname and lastname field. We will show how to implement this requirement by Hibernate Validator with two approach.
Part 1.1: Cross Field Validation By Customize Validator and Annotation
There will be 3 Steps. We need to create a new annotation, define the validator and apply the annotation to the POJO.
Part 1.1.1: Create new annotation
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/annotation/ValidateFirstnameAndLastnameSameLanguage.java 2@Target({ElementType.TYPE}) 3@Retention(RetentionPolicy.RUNTIME) 4@Constraint(validatedBy = FirstnameAndLastnameSameLanguageValidator.class) 5public @interface ValidateFirstnameAndLastnameSameLanguage { 6 String message() default "Firstname And Lastname must in same language"; 7 Class<?>[] groups() default { }; 8 Class<? extends Payload>[] payload() default { }; 9}
Part 1.1.2: Define Validator
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/annotation/validator/FirstnameAndLastnameSameLanguageValidator.java 2public class FirstnameAndLastnameSameLanguageValidator implements ConstraintValidator<ValidateFirstnameAndLastnameSameLanguage, ExampleForm> { 3 @Override 4 public boolean isValid(ExampleForm exampleForm, ConstraintValidatorContext context) { 5 if (isBlank(exampleForm.getFirstname()) || isBlank(exampleForm.getLastname())) { 6 return true; 7 } 8 if (isContainingEnglishAndSpaceOnly(exampleForm.getFirstname())) { 9 return isContainingEnglishAndSpaceOnly(exampleForm.getLastname()); 10 } 11 if (isChinese(exampleForm.getFirstname())) { 12 return isChinese(exampleForm.getLastname()); 13 } 14 return false; 15 } 16 ... Private Helper Method 17}
Part 1.1.2(Optional Part): Unit-Testing Defined Validator Logic
We can test our validator logic by junit.
JAVACopy1// validator-spring-boot-starter-example/src/test/java/com/validator/example/annotation/validator/FirstnameAndLastnameSameLanguageValidatorTest.java 2 @Test 3 void isValid() { 4 var validator = new FirstnameAndLastnameSameLanguageValidator(); 5 ExampleForm form = new ExampleForm(); 6 form.setFirstname("John"); 7 form.setLastname("Doe"); 8 Assertions.assertTrue(validator.isValid(form, null)); 9 10 form.setFirstname("John John"); 11 form.setLastname("Doe"); 12 Assertions.assertTrue(validator.isValid(form, null)); 13 14 form.setFirstname("小明"); 15 form.setLastname("陳"); 16 Assertions.assertTrue(validator.isValid(form, null)); 17 18 form.setFirstname("小明"); 19 form.setLastname("chan"); 20 Assertions.assertFalse(validator.isValid(form, null)); 21 22 form.setFirstname("John John"); 23 form.setLastname("陳"); 24 Assertions.assertFalse(validator.isValid(form, null)); 25 }
(Optional) The code can be simpified by @ParameterizedTest if you think there is too much repeated code.
JAVACopy1// // validator-spring-boot-starter-example/src/test/java/com/validator/example/annotation/validator/FirstnameAndLastnameSameLanguageValidatorTest.java 2 @ParameterizedTest 3 @CsvSource(value = { 4 "John,Doe,true", 5 "John John,Doe,true", 6 "小明,陳,true" 7 } 8 ) 9 void isValid(String firstname, String lastname, boolean isValid) { 10 var validator = new FirstnameAndLastnameSameLanguageValidator(); 11 ExampleForm exampleForm = new ExampleForm(); 12 exampleForm.setFirstname(firstname); 13 exampleForm.setLastname(lastname); 14 if (isValid) { 15 Assertions.assertTrue(validator.isValid(exampleForm, null)); 16 } else { 17 Assertions.assertFalse(validator.isValid(exampleForm, null)); 18 } 19 }
Part 1.1.3: Apply Annotation To POJO
Add our new annotation @ValidateFirstnameAndLastnameSameLanguage to the ExampleForm POJO.
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2@ValidateFirstnameAndLastnameSameLanguage 3public class ExampleForm { 4 private String firstname; 5 private String lastname; 6 private CollectionMethod collectionMethod; 7 private String mailReceiverFullName; 8 private Address address; 9 ... Getter, Setter, inner class 10}
Part 1.1.4: Test Our Annotation by validating the POJO
We can prove that our new annotation take effects by unit-testing.
JAVACopy1// validator-spring-boot-starter-example/src/test/java/com/validator/example/form/ExampleFormTest.java 2 @Test 3 void validateFirstnameAndLastnameSameLanguage() { 4 var validator = buildValidator(); 5 var exampleForm = new ExampleForm(); 6 exampleForm.setFirstname("John"); 7 exampleForm.setLastname("陳"); 8 Set<ConstraintViolation<ExampleForm>> validationResult = validator.validate(exampleForm); 9 Assertions.assertEquals(1, validationResult.size()); 10 Assertions.assertEquals("Firstname And Lastname must in same language.", validationResult.iterator().next().getMessage()); 11 }
Part 1.2: Cross Field Validation By @AssertTrue Annotation
Besides Self define annotation and validator, we can use @AssertTrue in the POJO class.
Part 1.2.1: Add @AssertTrue and isXXX Method.
We can add a method named with isXXX and return boolean. The logic of the method will be just same as above self defined validator.
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2 3// Comment Out Self Define Annotation First 4// @ValidateFirstnameAndLastnameSameLanguage 5public class ExampleForm { 6 private String firstname; 7 private String lastname; 8 private CollectionMethod collectionMethod; 9 private String mailReceiverFullName; 10 private Address address; 11 12 @AssertTrue(message = "Firstname And Lastname must in same language.") 13 private boolean isFirstnameAndLastnameSameLanguage() { 14 if (isBlank(firstname) || isBlank(lastname)) { 15 return true; 16 } 17 if (isContainingEnglishAndSpaceOnly(firstname)) { 18 return isContainingEnglishAndSpaceOnly(lastname); 19 } 20 if (isChinese(firstname)) { 21 return isChinese(lastname); 22 } 23 return false; 24 } 25 ... Getter, Setter, inner class, Private Helper Method 26}
Part 1.2.2: Run the same Unit-Test in Part 1.1.4
After running the test, we will find both approach can pass the test.
Part 1.2.3: Potential Problem of this approach
(Problem 1) If we set the isXXX method to public, the object mapper may take the value together. So we may need to set the isXXX method to private, or need to annotate it with @JsonIgnore.
JAVACopy1// validator-spring-boot-starter-example/src/test/java/com/validator/example/form/ExampleFormTest.java 2@Test 3 void toJson() throws JsonProcessingException { 4 ObjectWriter objectWriter = new ObjectMapper().writer().withDefaultPrettyPrinter(); 5 String jsonString = objectWriter.writeValueAsString(new ExampleForm()); 6 Assertions.assertEquals( 7 """ 8 { 9 "firstname" : null, 10 "lastname" : null, 11 "collectionMethod" : null, 12 "mailReceiverFullName" : null, 13 "address" : null, 14 "firstnameAndLastnameSameLanguage" : true 15 } 16 """.trim(), 17 jsonString 18 ); 19 }
Fix:
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2@JsonIgnore 3@AssertTrue(message = "Firstname And Lastname must in same language.") 4private boolean isFirstnameAndLastnameSameLanguage() { 5 ... 6}
(Problem 2) Besides, the property path of isXXX method will be XXX with the first name to lowercase. Our method isFirstnameAndLastnameSameLanguage will have a name firstnameAndLastnameSameLanguage.
Part 2: Conditional Validation
Let's say we have a new requirement that the mailReceiverFullName and address must contain valid value when collectionMethod is MAIL. This Part will talk about conditional validation on primitive field(including wrapper class) and custom define class field.
Part 2.1: Conditional Validation on primitive field
There are two step. We will define two interface to represent the two condition. Then implement the logic to decide to use which group.
Part 2.1.1: Define group interface and add groups in Constraint Annotation
We define two interface to represent two different groups.
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2public class ExampleForm { 3 private String firstname; 4 private String lastname; 5 private CollectionMethod collectionMethod; 6 @NotBlank(groups = collectionMethodIsMailGroup.class) //(3) 7 private String mailReceiverFullName; 8 private Address address; 9 10 interface collectionMethodIsInPersonGroup {} //(1) 11 interface collectionMethodIsMailGroup {} //(2) 12 ... 13}
The two interface (1) collectionMethodIsInPersonGroup and (2) collectionMethodIsMailGroup represent the conditions.
(3): Add the constraint Annotation and set the group to the condition represented.
Part 2.1.2: Set the validation group In Run Time
JAVACopy1// validator-spring-boot-starter-example/src/test/java/com/validator/example/form/ExampleFormTest.java 2 @Test 3 void validateConditionalMailReceiverName() { 4 ExampleForm form = new ExampleForm(); 5 // (1) Start 6 form.setLastname("lastname"); 7 form.setFirstname("firstname"); 8 form.setCollectionMethod(ExampleForm.CollectionMethod.MAIL); 9 form.setMailReceiverFullName(""); 10 // (1) End 11 12 var validator = buildValidator(); 13 Set<ConstraintViolation<ExampleForm>> validationResult; 14 15 // (2) Start 16 if (form.getCollectionMethod() == ExampleForm.CollectionMethod.MAIL) { 17 validationResult = validator.validate(form, ExampleForm.CollectionMethodIsMailGroup.class); 18 } else { 19 validationResult = validator.validate(form, ExampleForm.CollectionMethodIsInPersonGroup.class); 20 } 21 // (2) End 22 Assertions.assertEquals(1, validationResult.size()); 23 var constraintViolation = validationResult.iterator().next(); 24 Assertions.assertEquals("mailReceiverFullName", constraintViolation.getPropertyPath().toString()); 25 Assertions.assertEquals("must not be null.", validationResult.iterator().next().getMessage()); 26 27 // (3) Start 28 form.setCollectionMethod(ExampleForm.CollectionMethod.IN_PERSON); 29 30 if (form.getCollectionMethod() == ExampleForm.CollectionMethod.MAIL) { 31 validationResult = validator.validate(form, ExampleForm.CollectionMethodIsMailGroup.class); 32 } else { 33 validationResult = validator.validate(form, ExampleForm.CollectionMethodIsInPersonGroup.class); 34 } 35 Assertions.assertEquals(0, validationResult.size()); 36 // (3) End 37 }
(1): We set valid value for fields besides mailReceiverName as we want to test this field only on this method.
(2): This is the logic to decide the validation group. We decide by the field collectionMethod is MAIL or not. Then we can see there is validation constraint hit.
(3): To prove the @NotBlank Constraint on field mailReceiverName is only violated when collectionMethod is MAIL, we set the collectionMethod is IN_PERSON. There is no constraint this time.
Part 2.2: Conditional Validation on custom define class field
We implemented the requirement on the mailReceiverFullName. This part will implement address.
Part 2.2.0: Revisit Constraint in Address Class
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2... 3public static class Address { 4 @NotBlank 5 private String flat; 6 @NotBlank 7 private String street; 8 @NotBlank 9 private String city; 10} 11...
Part 2.2.1: Add Valid Annotation On Address
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2public class ExampleForm { 3 private String firstname; 4 private String lastname; 5 private CollectionMethod collectionMethod; 6 private String mailReceiverFullName; 7 @Valid // (1) 8 @NotNull(message = "must not be null.", groups = CollectionMethodIsMailGroup.class) // (2) 9 private Address address; 10 ... 11}
(1): we need to add @Valid on nested object as we will only call validate on ExampleForm class.
(2): we need to add @NotNull, otherwise it will not hit any constraint in the Address class when the value of address is null.
Part 2.2.2: Apply Conditional Validation
There is two approach.
Part 2.2.2.1: Add group directly on nested object
If the Address class will not be reused in other class and groups, we can add the groups inside the address class.
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2... 3 public static class Address { 4 @NotBlank(message = "must not be blank.", groups = CollectionMethodIsMailGroup.class) 5 private String flat; 6 @NotBlank(message = "must not be blank.", groups = CollectionMethodIsMailGroup.class) 7 private String street; 8 @NotBlank(message = "must not be blank.", groups = CollectionMethodIsMailGroup.class) 9 private String city; 10 } 11...
Then we can prove the conditional validation works
JAVACopy1// validator-spring-boot-starter-example/src/test/java/com/validator/example/form/ExampleFormTest.java 2 @Test 3 void validateAddress_conditional() { 4 ExampleForm form = new ExampleForm(); 5 form.setLastname("lastname"); 6 form.setFirstname("firstname"); 7 form.setMailReceiverFullName("mailReceiverFullName"); 8 form.setAddress(new ExampleForm.Address()); 9 form.setCollectionMethod(ExampleForm.CollectionMethod.MAIL); 10 var validator = buildValidator(); 11 Set<ConstraintViolation<ExampleForm>> validationResult; 12 if (form.getCollectionMethod() == ExampleForm.CollectionMethod.MAIL) { 13 validationResult = validator.validate(form, ExampleForm.CollectionMethodIsMailGroup.class); 14 } else { 15 validationResult = validator.validate(form, ExampleForm.CollectionMethodIsInPersonGroup.class); 16 } 17 Assertions.assertEquals(3, validationResult.size()); 18 var constraintViolationMap = toMap(validationResult); 19 assertInMap(constraintViolationMap, "address.flat", "must not be blank."); 20 assertInMap(constraintViolationMap, "address.street", "must not be blank."); 21 assertInMap(constraintViolationMap, "address.city", "must not be blank."); 22 23 form.setCollectionMethod(ExampleForm.CollectionMethod.IN_PERSON); 24 if (form.getCollectionMethod() == ExampleForm.CollectionMethod.MAIL) { 25 validationResult = validator.validate(form, ExampleForm.CollectionMethodIsMailGroup.class); 26 } else { 27 validationResult = validator.validate(form, ExampleForm.CollectionMethodIsInPersonGroup.class); 28 } 29 Assertions.assertEquals(0, validationResult.size()); 30 }
Part 2.2.2.2: Use @ConvertGrop
However, Address is likely to be a common class when the system grows. The approach above may affect the reuse of the Address Class. We can use @ConvertGroup Annotation and remove the groups in Address Class.
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2 3// remove the groups = CollectionMethodIsMailGroup.class 4public static class Address { 5 @NotBlank(message = "must not be blank.") 6 private String flat; 7 @NotBlank(message = "must not be blank.") 8 private String street; 9 @NotBlank(message = "must not be blank.") 10 private String city; 11}
JAVACopy1// validator-spring-boot-starter-example/src/main/java/com/validator/example/form/ExampleForm.java 2public class ExampleForm { 3 private String firstname; 4 private String lastname; 5 private CollectionMethod collectionMethod; 6 @NotBlank(message = "must not be blank.", groups = CollectionMethodIsMailGroup.class) 7 private String mailReceiverFullName; 8 9 @ConvertGroup(from = Default.class, to = NoValidationGroup.class) // (2) 10 @ConvertGroup(from = CollectionMethodIsMailGroup.class, to = Default.class) // (2) 11 @NotNull(message = "must not be null.", groups = CollectionMethodIsMailGroup.class) 12 @Valid 13 private Address address; 14 15 interface NoValidationGroup{} // (1) 16 interface CollectionMethodIsInPersonGroup {} 17 interface CollectionMethodIsMailGroup {} 18}
(1): we define a no validation interface, prepare for (2).
(2): if we do not put value of groups on constraint annotation, the Default.class will be applied. In order to implement condition validation, we use two @ConvertGroup annotation. The first one switch the Default.class to NoValidationGroup.class. As we will never apply NoValidationGroup.class on validate() method, the default validation is removed. The second one switch our conditional group to Default group. Then we will have default validation only when we apply CollectionMethodIsMailGroup.class on validate() method.
Part 3: i18n
if we want to control the language used in validator. We can implement LocaleResolver interface and config it when build the validator, otherwise, the DefaultLocaleResolver will be used. Below is a simple example.
Part 3.1: Implement LocaleResolver interface
JAVACopy1// validator-spring-boot-starter/src/main/java/com/validator/core/FixedLocaleResolver.java 2public class FixedLocaleResolver implements LocaleResolver { 3 private final Locale locale; 4 5 public FixedLocaleResolver(Locale locale) { 6 this.locale = locale; 7 } 8 9 public static FixedLocaleResolver of(Locale locale) { 10 return new FixedLocaleResolver(locale); 11 } 12 13 @Override 14 public Locale resolve(LocaleResolverContext localeResolverContext) { 15 return locale; 16 } 17}
Part 3.2: Config when build validator
JAVACopy1// validator-spring-boot-starter-example/src/test/java/com/validator/example/form/ExampleFormTest.java 2private Validator buildValidator(Locale locale) { 3 return Validation.byProvider( HibernateValidator.class ) 4 .configure() 5 .messageInterpolator( 6 new ParameterMessageInterpolator(Set.of(locale), locale, FixedLocaleResolver.of(locale), false) 7 ) 8 .buildValidatorFactory() 9 .getValidator(); 10}
Part 3.3: Unit-Testing
Then we can build the validator which use the fixed locale.
JAVACopy1// validator-spring-boot-starter-example/src/test/java/com/validator/example/form/ExampleFormTest.java 2... 3 @Test 4 void testLocale() { 5 var englishValidator = buildValidator(Locale.ENGLISH); 6 var englishValidationResult = englishValidator.validate(new SimplePoJo()); 7 Assertions.assertEquals(1, englishValidationResult.size()); 8 Assertions.assertEquals("must not be blank", englishValidationResult.iterator().next().getMessage()); 9 var chineseValidator = buildValidator(Locale.TRADITIONAL_CHINESE); 10 var chineseValidationResult = chineseValidator.validate(new SimplePoJo()); 11 Assertions.assertEquals(1, englishValidationResult.size()); 12 Assertions.assertEquals("不得空白", chineseValidationResult.iterator().next().getMessage()); 13 } 14...
Part 4: Error Field Message
For the error field, we can use the getPropertyPath() to represent the error field name. Hibernate Validator will use the bean name as the propertyPath.
JAVACopy1//validator-spring-boot-starter-example/src/test/java/com/validator/example/form/ExampleFormTest.java 2... 3Assertions.assertEquals("address", constraintViolation.getPropertyPath().toString()); 4Assertions.assertEquals("must not be null.", validationResult.iterator().next().getMessage()); 5... 6// Nested Field 7assertInMap(constraintViolationMap, "address.flat", "must not be blank."); 8assertInMap(constraintViolationMap, "address.street", "must not be blank."); 9assertInMap(constraintViolationMap, "address.city", "must not be blank."); 10...
Part 4.1: Brute Force Solution
I am still finding how to customize the error field name properly. There is a Brute Force Solution, which build a message bundle by Annotation, using the propertyPath as key and output the name using the message bundle value. Below is the example.
JAVACopy1// The example DTO for example 2// validator-spring-boot-starter/src/test/java/com/validator/test/TestingOnlineShoppingForm.java 3... 4@FieldName("Online Shopping Form") 5@FieldName(value = "網上購物表格", locale = LocaleConstant.TRADITIONAL_CHINESE) 6@FieldName(value = "网上购物表格", locale = LocaleConstant.SIMPLIFIED_CHINESE) 7public class TestingOnlineShoppingForm { 8 ... 9 @FieldName("Contact First Name") 10 @FieldName(value = "聯絡姓名 (名字)", locale = LocaleConstant.TRADITIONAL_CHINESE) 11 @FieldName(value = "联系姓名 (名字)", locale = LocaleConstant.SIMPLIFIED_CHINESE) 12 private String firstname; 13 ... 14 @FieldName("Selected Shipping Method") 15 @FieldName(value = "選擇的運輸方式", locale = LocaleConstant.TRADITIONAL_CHINESE) 16 @FieldName(value = "选择的运输方式", locale = LocaleConstant.SIMPLIFIED_CHINESE) 17 private ShippingMethod shippingMethod; 18 ... 19 @FieldName("Shopping Cart") 20 @FieldName(value = "購物車", locale = LocaleConstant.TRADITIONAL_CHINESE) 21 @FieldName(value = "购物车", locale = LocaleConstant.SIMPLIFIED_CHINESE) 22 private List<@Valid 23 @FieldName("Shopping Cart Item") 24 @FieldName(value = "購物車物品", locale = LocaleConstant.TRADITIONAL_CHINESE) 25 @FieldName(value = "购物车物品", locale = LocaleConstant.SIMPLIFIED_CHINESE) 26 ShoppingItem> shoppingItemList; 27 ... 28}
JAVACopy1// validator-spring-boot-starter/src/test/java/com/validator/service/ValidationServiceImplTest.java 2@Test 3void validateByTraditionalChineseLocale() { 4 TestingOnlineShoppingForm testingOnlineShoppingForm = getTestingOnlineShoppingForm(); 5 var validationResult = validationService.validate(testingOnlineShoppingForm, Locale.TRADITIONAL_CHINESE); 6 printConstraintViolationWrapper(validationResult); 7 ... 8} 9 10... 11TranslatedErrorFieldName: 網上購物表格 - 聯絡姓名 (名字). 12TranslatedMessage: 不得空白. 13Locale: zh_TW. 14... 15TranslatedErrorFieldName: 網上購物表格 - 選擇的運輸方式. 16TranslatedMessage: 不得是空值. 17Locale: zh_TW. 18 19TranslatedErrorFieldName: 網上購物表格 - 網上購物表格 - 購物車 - 第1項 - 購物車物品 - 物品名. 20TranslatedMessage: 不得空白. 21Locale: zh_TW. 22 23TranslatedErrorFieldName: 網上購物表格 - 網上購物表格 - 購物車 - 第1項 - 購物車物品 - 物品數目. 24TranslatedMessage: 不得是空值. 25Locale: zh_TW.
If you are interested in this solution, can visit https://github.com/kenwu565657/validator-spring-boot-starter-github-repo for more details.