Skip to content

Instantly share code, notes, and snippets.

@aoudiamoncef
Last active March 9, 2024 06:33
Show Gist options
  • Save aoudiamoncef/9eeece142d1ef0faa4d06216a41282a2 to your computer and use it in GitHub Desktop.
Save aoudiamoncef/9eeece142d1ef0faa4d06216a41282a2 to your computer and use it in GitHub Desktop.
Spring Boot Custom Bean Validations with Jakarta ConstraintValidator, Grouping Validation Constraints, GroupSequence and i18n messages

Spring Boot Custom Bean Validations with Jakarta ConstraintValidator, Grouping Validation Constraints, GroupSequence and i18n messages

This project demonstrates the implementation of custom bean validations in a Spring Boot application. It focuses on validating mobile phone information, particularly IMEI numbers, using Hibernate Validator and Spring Boot.

Overview

Spring Boot allows developers to define custom bean validations by creating custom constraint annotations and implementing constraint validators. This approach provides flexibility in defining validation rules tailored to the application's requirements.

Structure

.
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── maoudia
    │   │           ├── AppConfig.java
    │   │           ├── AppValidator.java
    │   │           ├── Application.java
    │   │           ├── Full.java
    │   │           ├── Heavy.java
    │   │           ├── IMEI.java
    │   │           ├── IMEIValidator.java
    │   │           ├── Lite.java
    │   │           └── MobilePhone.java
    │   └── resources
    │       ├── messages.properties
    │       └── messages_fr.properties
    └── test
        └── java
            └── com
                └── maoudia
                    ├── LocalizationTests.java
                    └── MobilePhoneValidationsTests.java

Components

AppConfig.java

  • Spring configuration class defining beans for message source and validator factory.
  • Sets up the message source for localization and validator factory for validation.

AppProperties.java

  • Contains properties for the application configuration.

AppValidator.java

  • Spring component providing a generic validation method to validate objects using Hibernate Validator.
  • Configured with a local validator factory bean to perform validations.

Full.java

  • Interface defining a validation group for full validation.

Heavy.java

  • Interface defining a validation group for heavy validation.

IMEI.java

  • Annotation interface marking fields to be validated as IMEI numbers.
  • References the IMEIValidator class for validation.

IMEIValidator.java

  • Custom constraint validator for validating IMEI numbers.
  • Checks the IMEI's format and performs the Luhn algorithm to ensure validity.

Lite.java

  • Interface defining a validation group for lite validation.

MobilePhone.java

  • Java record class defining the structure of a mobile phone.
  • Includes fields such as brand, model, screen size, battery capacity, network, IMEI, and MAC address.
  • Fields have corresponding validation annotations from Hibernate Validator.

messages.properties / messages_fr.properties

  • Files containing error messages for localization in English and French, respectively.

Tests

LocalizationTests.java

  • JUnit test class demonstrating localization testing by checking localized error messages for IMEI validation with English and French locales.

MobilePhoneValidationsTests.java

  • JUnit test class containing tests for validating the MobilePhone class using the AppValidator component.

Usage

To use the application:

  1. Define a MobilePhone object with the desired mobile phone information.
  2. Pass the MobilePhone object to the AppValidator for validation.
  3. Handle any validation errors appropriately based on the business logic of your application.

Running Tests

  1. Ensure you have JDK and Apache Maven installed.
  2. Execute mvn test command to run all tests.
  3. Review the test results to ensure all validations pass as expected.

Conclusion

This project provides a framework for validating mobile phone information, specifically IMEI numbers, using Hibernate Validator and Spring Boot. It demonstrates the usage of Spring Boot Custom Bean Validations, allowing developers to define custom validation rules for domain objects. Developers can customize and expand this framework to suit the validation needs of their applications.

References

package com.maoudia;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
/**
* The {@code AppConfig} class configures beans for message source and validator factory.
*/
@Configuration
public class AppConfig {
/**
* Configures the message source bean for resolving validation messages.
*
* @return the configured message source bean
*/
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:messages");
messageSource.setDefaultEncoding("ISO-8859-1");
return messageSource;
}
/**
* Configures the validator factory bean with the provided message source.
*
* @param messageSource the message source bean to be used by the validator factory
* @return the configured validator factory bean
*/
@Bean
public LocalValidatorFactoryBean localValidatorFactoryBean(MessageSource messageSource) {
LocalValidatorFactoryBean validatorFactoryBean = new LocalValidatorFactoryBean();
validatorFactoryBean.setValidationMessageSource(messageSource);
return validatorFactoryBean;
}
}
package com.maoudia;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Validator;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.groups.Default;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* The {@code AppValidator} class provides methods to validate objects against specified validation groups.
*/
@Component
public class AppValidator {
private final Validator validator;
/**
* Constructs an {@code AppValidator} with the specified validator.
*
* @param validator the validator used for validation
*/
public AppValidator(Validator validator) {
this.validator = validator;
}
/**
* Validates the given object against the default validation group.
*
* @param object the object to validate
* @param <T> the type of the object
*/
public <T> void validate(@NotNull T object) {
this.validate(object, Default.class);
}
/**
* Validates the given object against the specified validation groups.
*
* @param object the object to validate
* @param groups the validation groups to apply
* @param <T> the type of the object
*/
public <T> void validate(@NotNull T object, @NotNull Class<?>... groups) {
Set<ConstraintViolation<T>> violations = this.validator.validate(object, groups);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
package com.maoudia;
import jakarta.validation.GroupSequence;
import jakarta.validation.groups.Default;
/**
* The {@code Full} interface represents a validation group sequence including {@link Default}, {@link Lite}, and {@link Heavy} groups.
* This sequence defines the order in which validation constraints should be applied.
*/
@GroupSequence({Default.class, Lite.class, Heavy.class})
public interface Full {
}
package com.maoudia;
/**
* The {@code Heavy} interface represents a validation group for heavy validations.
* Classes annotated with this interface will undergo heavy validation checks.
*/
public interface Heavy {
}
package com.maoudia;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* The {@code IMEI} annotation declares a field-level constraint for validating International Mobile Equipment Identity (IMEI) numbers.
* It specifies the {@link IMEIValidator} class to perform the validation.
*/
@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = IMEIValidator.class)
@Documented
public @interface IMEI {
/**
* Retrieves the error message template used when the validation fails.
*
* @return the error message template
*/
String message() default "{app.validation.constraints.IMEI.message}";
/**
* Retrieves the validation groups to which this constraint belongs.
*
* @return the validation groups
*/
Class<?>[] groups() default { };
/**
* Retrieves the payload classes associated with the constraint.
*
* @return the payload classes
*/
Class<? extends Payload>[] payload() default {};
}
package com.maoudia;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
/**
* The {@code IMEIValidator} class implements the ConstraintValidator interface for validating IMEI (International Mobile Equipment Identity).
* It checks whether the provided IMEI string is valid, according to the IMEI pattern and checksum algorithm.
*/
public class IMEIValidator implements ConstraintValidator<IMEI, String> {
/**
* Regular expression pattern for matching the IMEI format (exactly 15 digits).
*/
private static final String IMEI_PATTERN = "^[0-9]{15}$";
private static final Pattern IMEI_REGEX_PATTERN = Pattern.compile(IMEI_PATTERN);
@Override
public void initialize(IMEI constraintAnnotation) {
// No initialization needed
}
/**
* Validates the provided IMEI string.
*
* @param value the IMEI string to validate
* @param context the validation context
* @return true if the IMEI string is valid, false otherwise
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return false;
}
if (!IMEI_REGEX_PATTERN.matcher(value).matches()) {
return false;
}
try {
long imei = Long.parseLong(value);
return isValidIMEI(imei);
} catch (NumberFormatException e) {
return false;
}
}
/**
* Validates the checksum of the provided IMEI number.
*
* @param imei the IMEI number to validate
* @return true if the checksum is valid, false otherwise
*/
private static boolean isValidIMEI(long imei) {
int sum = 0;
boolean doubleNext = false;
for (int i = 14; i >= 0; i--) {
int digit = (int) (imei % 10);
imei /= 10;
if (doubleNext) {
digit *= 2;
digit = digit % 10 + digit / 10; // Add digits of the double result
}
sum += digit;
doubleNext = !doubleNext;
}
return sum % 10 == 0;
}
}
package com.maoudia;
/**
* The {@code Lite} interface represents a validation group for lite validations.
* Classes annotated with this interface will undergo lite validation checks.
*/
public interface Lite {
}
package com.maoudia;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class LocalizationTests {
@Autowired
private MessageSource messageSource;
@Test
@DisplayName("Test Localization With English Locale")
public void testLocalizationWithEnglishLocale() {
// Set the locale to English
LocaleContextHolder.setLocale(Locale.ENGLISH);
// Resolve the localized message
String message = messageSource.getMessage("app.validation.constraints.IMEI.message", null, LocaleContextHolder.getLocale());
// Assert the localized message
assertEquals("must be a valid IMEI", message);
}
@Test
@DisplayName("Test Localization With French Locale")
public void testLocalizationWithFrenchLocale() {
// Set the locale to French
LocaleContextHolder.setLocale(Locale.FRENCH);
// Resolve the localized message
String message = messageSource.getMessage("app.validation.constraints.IMEI.message", null, LocaleContextHolder.getLocale());
// Assert the localized message
assertEquals("doit être un IMEI valide", message);
}
}
app.validation.constraints.IMEI.message=must be a valid IMEI
app.validation.constraints.IMEI.message=doit être un IMEI valide
package com.maoudia;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
/**
* The {@code MobilePhone} record represents a mobile phone entity with various properties.
* This record is used for mobile phone data representation and validation.
*/
public record MobilePhone(
/*
* The ID of the mobile phone.
*/
@Positive
long id,
/*
* The brand of the mobile phone.
*/
@NotBlank(groups = Lite.class)
String brand,
/*
* The model of the mobile phone.
*/
@NotBlank(groups = Lite.class)
String model,
/*
* The screen size of the mobile phone.
*/
@Positive(groups = Lite.class)
double screenSize,
/*
* The battery capacity of the mobile phone.
*/
@Positive(groups = Lite.class)
int batteryCapacity,
/*
* The network type of the mobile phone.
*/
@NotBlank(groups = Lite.class)
String network,
/*
* The IMEI (International Mobile Equipment Identity) of the mobile phone.
*/
@IMEI(groups = Heavy.class)
String imei,
/*
* The MAC (Media Access Control) address of the mobile phone.
*/
@Pattern(regexp = "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", groups = Heavy.class)
String macAddress
) {
}
package com.maoudia;
import jakarta.validation.ConstraintViolationException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MobilePhoneValidationsTests {
@Autowired
private AppValidator appValidator;
@Test
@DisplayName("Validate valid MobilePhone with default group")
void testValidIdDefault() {
MobilePhone mobilePhone = new MobilePhone(1, "Apple", "iPhone 15 Pro Max", 6.7, 4352, "5G", "invalid_imei", "invalid_mac_address");
assertThatCode(() -> this.appValidator.validate(mobilePhone)).doesNotThrowAnyException();
}
@Test
@DisplayName("Validate invalid MobilePhone with default group")
void testInvalidIdDefault() {
MobilePhone mobilePhone = new MobilePhone(0, "Apple", "iPhone 15 Pro Max", 6.7, 4352, "5G", "invalid_imei", "invalid_mac_address");
assertThrows(ConstraintViolationException.class, () -> this.appValidator.validate(mobilePhone));
}
@Test
@DisplayName("Validate valid Mac Address format with Lite group")
void testValidMacAddressFormatLite() {
MobilePhone mobilePhone = new MobilePhone(1, "Apple", "iPhone 15 Pro Max", 6.7, 4352, "5G", null, "00:11:22:33:44:55");
assertThatCode(() -> this.appValidator.validate(mobilePhone, Lite.class)).doesNotThrowAnyException();
}
@Test
@DisplayName("Validate invalid Mac Address format with Lite group")
void testInvalidMacAddressFormatLite() {
MobilePhone mobilePhone = new MobilePhone(1,"Apple", "iPhone 15 Pro Max", 6.7, 4352, "5G", "452988763084802", "invalid_mac_address");
assertThrows(ConstraintViolationException.class, () -> this.appValidator.validate(mobilePhone, Heavy.class));
}
@Test
@DisplayName("Validate valid IMEI format with Heavy group")
void testValidIMEIFormatHeavy() {
MobilePhone mobilePhone = new MobilePhone(1,"Apple", "iPhone 15 Pro Max", 6.7, 4352, "5G", "452988763084802", "00:11:22:33:44:55");
assertThatCode(() -> this.appValidator.validate(mobilePhone, Heavy.class)).doesNotThrowAnyException();
}
@Test
@DisplayName("Validate invalid IMEI format with Heavy group")
void testInvalidIMEIFormatHeavy() {
MobilePhone mobilePhone = new MobilePhone(1,"Apple", "iPhone 15 Pro Max", 6.7, 4352, "5G", "invalid_imei", "00:11:22:33:44:55");
assertThrows(ConstraintViolationException.class, () -> this.appValidator.validate(mobilePhone, Heavy.class));
}
@Test
@DisplayName("Validate full validation with Full group")
void testFullValidation() {
MobilePhone mobilePhone = new MobilePhone(1,"Apple", "iPhone 15 Pro Max", 6.7, 4352, "5G", "452988763084802", "00:11:22:33:44:55");
assertThatCode(() -> this.appValidator.validate(mobilePhone, Full.class)).doesNotThrowAnyException();
}
}
<?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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.maoudia</groupId>
<artifactId>app</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>maoudia-app</name>
<description>MAOUDIA APP</description>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<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-validation</artifactId>
</dependency>
<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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment