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"); }
}
-
@Target({ElementType.TYPE})
: Indicates that this annotation targets a type declaration.
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 {};
}
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
Discover more from Coursity
Subscribe to get the latest posts sent to your email.