Published on

[Java] - How to write a custom Java annotation?

Authors
  • avatar
    Name
    David Nguyen
    Twitter
Table of Contents

1. What is a Java annotation?

  • A Java annotation is a special form of metadata that can be added to Java code elements such as classes, methods, fields, parameters, or constructors.

  • Annotations provide additional information to the compiler, tools, or the runtime environment without affecting the actual logic of the code.

  • Annotations in Java can be used for a variety of purposes, such as:

    • Code Documentation: Annotations like @Deprecated or @Override provide information about the code structure and intended behavior.
    • Code Analysis: Tools and frameworks can analyze annotated code to generate boilerplate code, enforce specific rules, or perform validation.
    • Runtime Behavior: Some annotations can be processed at runtime to modify the behavior of a program dynamically, for example, with dependency injection or transaction management in Spring.

=> We can also create custom annotations, and in this article I show you guys how to create a custom annotation for validating phone numbers.

2. How to define an annotation.

  • To define an annotation, we will use @interface:
public @interface PhoneValidator {
    String message() default "Invalid phone number";
}
  • You can apply the annotation to the fields you want to validate. However, it will only appear in your code and will not perform any action on its own.
public class CustomerCreateReq {
    @PhoneValidator(message = "Enter a valid phone number.")
    String phone;
}
  • We need to write more code to make our annotation can be processed when the phone number actually invalid.

2.1 - Declare annotation's scopes.

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidatorImpl.class)
public @interface PhoneValidator {
    String country() default "VN";
    String message() default "Invalid phone number";
}
  • @Target: Define scope of using of the annotations.

    • ElementType.TYPE: Allow to annotated on classes, interfaces, enums.
    • ElementType.FIELD: Allow to annotated on fields (include enums).
    • ElementType.METHOD: Allow to annotated on methods.
    • ElementType.PARAMETER: Allow to annotated on parameters.
    • ElementType.CONSTRUCTOR: Allow to annotated on constructors.
    • ElementType.LOCAL_VARIABLE: Allow to annotated on local variables.
    • ElementType.ANNOTATION_TYPE: Allow to annotated on other annotations.
    • ElementType.PACKAGE: Allow to annotated on package.
  • @Retention: Used to indicate the retention level of an annotation. There are three levels of retention awareness for the annotated element.

    • RetentionPolicy.SOURCE: This retention policy is only available in the source code and it is ignored by the compiler.
    • RetentionPolicy.CLASS: This retention is stored in .class files, but they are not available at runtime.
    • RetentionPolicy.RUNTIME: This retention is stored in .class files, and available during runtime.
  • @Constraint(validatedBy = PhoneValidatorImpl.class): This specifies that the class PhoneValidatorImpl contains the logic that will handle the validation process.

2.2 - Implement logic for annotation.

public enum CountryEnums {
    VN,
    US
}
public class PhoneValidatorImpl implements ConstraintValidator<PhoneValidator, String> {
    private static final Map<String, String> COUNTRY_PHONE_REGEX = new HashMap<>();

    static {
        COUNTRY_PHONE_REGEX.put(String.valueOf(CountryEnums.VN), "^(03|05|07|08|09)\\d{8}$");
        COUNTRY_PHONE_REGEX.put(String.valueOf(CountryEnums.US), "^\\+1\\d{10}$");
    }

    private String country;

    @Override
    public void initialize(PhoneValidator constraintAnnotation) {
        this.country = constraintAnnotation.country();
    }

    @Override
    public boolean isValid(String phone, ConstraintValidatorContext constraintValidatorContext) {
        if (phone == null || phone.isEmpty()) {
            return false;
        }

        String regex = COUNTRY_PHONE_REGEX.get(country);
        if (regex == null) {
            return false;
        }

        Pattern pattern = Pattern.compile(regex);
        return pattern.matcher(phone).matches();
    }
}
  • To implement logic for the annotation we will implement class ConstraintValidator<A extends Annotation, T>.

  • Then we have to override two methods initialize() and isValid() :

    • initialize(): For initializing values and in this case we need to load type of country phone number via using country variable then get the matched regex with that phone number.
    • isValid(): Where we implement the logic to validate the phone number by using Pattern.compile(regex) method to check if provided phone number is matched with pattern or not.

3. Write the unit tests.

  • To ensure our validator work correctly, we need to write unit tests for it.

  • Our PhoneValidator is used to validate Vietnam and United State phone numbers. So the test cases will be included:

      1. Test valid Vietnam phone numbers.
      1. Test invalid Vietnam phone numbers.
      1. Test valid US phone numbers.
      1. Test invalid US phone numbers.
      1. Test with an unsupported phone number (it might not be a Vietnam or US phone number).
      1. Edge case: Provided phone number is null.
      1. Edge case: Provided phone number is empty.
public class PhoneValidatorTest {
    private PhoneValidatorImpl phoneValidator;
    private ConstraintValidatorContext context;

    @BeforeEach
    public void setUp() {
        phoneValidator = new PhoneValidatorImpl();
        context = mock(ConstraintValidatorContext.class);

        PhoneValidator phoneValidatorAnnotationVn = Mockito.mock(PhoneValidator.class);
        when(phoneValidatorAnnotationVn.country()).thenReturn(CountryEnums.VN.name());

        phoneValidator.initialize(phoneValidatorAnnotationVn);
    }

    @Test
    public void testValidVietnamPhoneNumber() {
        assertTrue(phoneValidator.isValid("0912345678", context));
        assertTrue(phoneValidator.isValid("0312345678", context));
        assertTrue(phoneValidator.isValid("0812345678", context));
    }

    @Test
    public void testInvalidVietnamPhoneNumber() {
        assertFalse(phoneValidator.isValid("123456789", context)); // Not matching pattern
        assertFalse(phoneValidator.isValid("0212345678", context)); // Invalid start digits
        assertFalse(phoneValidator.isValid("09123456789", context)); // Too long
    }

    @Test
    public void testValidUSPhoneNumber() {
        PhoneValidator phoneValidatorAnnotation = Mockito.mock(PhoneValidator.class);
        when(phoneValidatorAnnotation.country()).thenReturn(CountryEnums.US.name());
        phoneValidator.initialize(phoneValidatorAnnotation);

        assertTrue(phoneValidator.isValid("+11234567890", context));
    }

    @Test
    public void testInvalidUSPhoneNumber() {
        assertFalse(phoneValidator.isValid("1234567890", context)); // Missing country code
        assertFalse(phoneValidator.isValid("+1234567890", context)); // Missing digit
        assertFalse(phoneValidator.isValid("+112345678901", context)); // Too long
    }

    @Test
    public void testUnsupportedCountry() {
        PhoneValidator phoneValidatorAnnotation = Mockito.mock(PhoneValidator.class);
        when(phoneValidatorAnnotation.country()).thenReturn("NONE");
        phoneValidator.initialize(phoneValidatorAnnotation);

        assertFalse(phoneValidator.isValid("0912345678", context)); // Unsupported country
    }

    @Test
    public void testNullPhoneNumber() {
        assertFalse(phoneValidator.isValid(null, context));
    }

    @Test
    public void testEmptyPhoneNumber() {
        assertFalse(phoneValidator.isValid("", context));
    }
}

4. Use the annotation.

  • To use the annotation, we can apply it to the fields we want to validate. For example:
public class CustomerCreateReq {
        // other fields

        @PhoneValidator(message = "Phone number is not valid in US.", country = "US")
        private String phone;
}

5. Conclusion.

  • In conclusion, custom annotations are an excellent way to handle common validations in a Java project. However, implementing a custom annotations requires more code, so it's important to carefully consider whether it's really necessary for your use case.

See you in the next posts. Happy Coding!