Creating Custom Annotations for Validation in Spring Boot
1. Overview
While Spring standard annotations (@NotBlank
, @NotNull
, @Min
, @Size
, etc.) cover many use cases when validating user input, there are times when we need to create custom validation logic for a more specific type of input. In this article, I will demonstrate how to create custom annotations for validation.
2. Setup
We need to add the spring-boot-starter-validation
dependency to our pom.xml
file.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
3. Custom Field Level Validation
3.1 Creating the Annotation
Let’s create custom annotations to validate file attributes, such as file extension, file size, and MIME type.
-
ValidFileExtension
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
validatedBy = {FileExtensionValidator.class}
)
public @interface ValidFileExtension {
String[] extensions() default {};
String message() default "{constraints.ValidFileExtension.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
-
ValidFileMaxSize
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
validatedBy = {FileMaxSizeValidator.class}
)
public @interface ValidFileMaxSize {
long maxSize() default Long.MAX_VALUE; // MB
String message() default "{constraints.ValidFileMaxSize.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
-
FileMimeTypeValidator
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
validatedBy = {FileMimeTypeValidator.class}
)
public @interface ValidFileMimeType {
String[] mimeTypes() default {};
String message() default "{constraints.ValidFileMimeType.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Let's break down these annotations' components:
-
@Constraint
: Specifies the validator class responsible for the validation logic. -
@Target({ElementType.FIELD})
: Indicates that this annotation can only be applied to fields. -
message()
: The default error message if the validation fails.
3.2 Creating the Validator
-
FileExtensionValidator
public class FileExtensionValidator implements ConstraintValidator<ValidFileExtension, MultipartFile> {
private List<String> extensions;
@Override
public void initialize(ValidFileExtension constraintAnnotation) {
extensions = List.of(constraintAnnotation.extensions());
}
@Override
public boolean isValid(MultipartFile file, ConstraintValidatorContext constraintValidatorContext) {
if (file == null || file.isEmpty()) {
return true;
}
var extension = FilenameUtils.getExtension(file.getOriginalFilename());
return StringUtils.isNotBlank(extension) && extensions.contains(extension.toLowerCase());
}
}
-
FileMaxSizeValidator
public class FileMaxSizeValidator implements ConstraintValidator<ValidFileMaxSize, MultipartFile> {
private long maxSizeInBytes;
@Override
public void initialize(ValidFileMaxSize constraintAnnotation) {
maxSizeInBytes = constraintAnnotation.maxSize() * 1024 * 1024;
}
@Override
public boolean isValid(MultipartFile file, ConstraintValidatorContext constraintValidatorContext) {
return file == null || file.isEmpty() || file.getSize() <= maxSizeInBytes;
}
}
-
FileMimeTypeValidator
@RequiredArgsConstructor
public class FileMimeTypeValidator implements ConstraintValidator<ValidFileMimeType, MultipartFile> {
private final Tika tika;
private List<String> mimeTypes;
@Override
public void initialize(ValidFileMimeType constraintAnnotation) {
mimeTypes = List.of(constraintAnnotation.mimeTypes());
}
@SneakyThrows
@Override
public boolean isValid(MultipartFile file, ConstraintValidatorContext constraintValidatorContext) {
if (file == null || file.isEmpty()) {
return true;
}
var detect = tika.detect(TikaInputStream.get(file.getInputStream()));
return mimeTypes.contains(detect);
}
}
These classes are implementations of the ConstraintValidator
interface and contain the actual validation logic.
For FileMimeTypeValidator
, we will use Apache Tika (a toolkit designed to extract metadata and content from numerous types of documents).
3.3 Applying the Annotation
Let's create a TestUploadRequest
class intended for handling file uploads, specifically for a PDF file.
@Data
public class TestUploadRequest {
@NotNull
@ValidFileMaxSize(maxSize = 10)
@ValidFileExtension(extensions = {"pdf"})
@ValidFileMimeType(mimeTypes = {"application/pdf"})
private MultipartFile pdfFile;
}
@RestController
@Validated
@RequestMapping("/test")
public class TestController {
@PostMapping(value = "/upload", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<String> testUpload(@Valid @ModelAttribute TestUploadRequest request) {
return ResponseEntity.ok("test upload");
}
}
4. Custom Class Level Validation
A custom validation annotation can also be defined at the class level to validate a combination of fields within a class.
4.1 Creating the Annotation
Let’s create @PasswordMatches
annotation to ensure that two password fields match in a class.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
validatedBy = {PasswordMatchesValidator.class}
)
public @interface PasswordMatches {
String message() default "{constraints.PasswordMatches.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
-
@Target({ElementType.TYPE})
: Indicates that this annotation targets a type declaration.
4.2 Creating the Validator
-
PasswordDto
public interface PasswordDto {
String getPassword();
String getConfirmPassword();
}
-
PasswordMatchesValidator
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, PasswordDto> {
@Override
public boolean isValid(PasswordDto password, ConstraintValidatorContext constraintValidatorContext) {
return StringUtils.equals(password.getPassword(), password.getConfirmPassword());
}
}
The PasswordDto
interface is an interface for objects that contain a password and a confirm password field.
The PasswordMatchesValidator
class implements the ConstraintValidator
interface and contains the logic for validating that the password and confirm password fields match.
4.3 Applying the Annotation
Let's create a RegisterAccountRequest
class intended for handling user registration data.
@PasswordMatches
@Data
public class RegisterAccountRequest implements PasswordDto {
@NotBlank
private String username;
@NotBlank
@Email
private String email;
@NotBlank
@ToString.Exclude
private String password;
@NotBlank
@ToString.Exclude
private String confirmPassword;
}
@RestController
@Validated
@RequestMapping("/auth")
public class AuthController {
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody @Valid RegisterAccountRequest request) {
return ResponseEntity.ok("register success");
}
}
5. Summary
In this short article, we discovered how easy it is to create custom annotations to verify a field or class. The code from this article is available over on my Github.
6. References
- Baeldung. (n.d.). Spring MVC Custom Validator. Retrieved from https://www.baeldung.com/spring-mvc-custom-validator