Easily configure validators via properties in a Spring Boot project

The annotations from the Java Validation API provide a convenient and flexible tool to ensure the quality of an application’s input. Some of them have parameters that allow for the validation’s fine tuning. The main drawback is that the parameters’ values are resolved at compile time, that’s why they must be static final constants. We’ll show how we can overcome this limitation and configure validators via properties in a Spring Boot project.

It would be great if we could set the parameters’ values through configuration properties. They could be updated easily without the need to redeploy the application, if they were defined in a .properties or .yml file. Unfortunately this is not possible with a standard annotation from the Java Validation API.

An example of such annotation is the @javax.validation.constraints.Size annotation, which allows to set maximum and minimum size for a CharSequence, a Collection, a Map or an array. In the following example we’ll create our own customized version that will retrieve max and min parameters from the application’s properties.

The project

We’ll build upon a project from a previous post. We’re going to create a new custom constraint, apply it to our model class, and update our test suites.

The custom constraint and its validator

First of all we need to create a custom constraint annotation. Its name will be @ConfigurableSize and it will be very similar to the @Size annotation. The main difference is that instead of a max and min parameter of type int we have a minProperty and a maxProperty parameter of type String. Those parameters will default to an empty string, which means that we don’t need to provide a value when using the annotation. Each parameter will hold the name of a configuration property that resolves to an int value.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(ConfigurableSize.List.class)
@Constraint(validatedBy = {ConfigurableSizeCharSequenceValidator.class})
public @interface ConfigurableSize {

    String message() default "size is not valid";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String minProperty() default "";

    String maxProperty() default "";

    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
    @Retention(RUNTIME)
    @Documented
    @interface List {
        ConfigurableSize[] value();
    }

}

In our example we’re only providing a validator for CharSequences, but the validators for Collections, Maps and arrays would be very similar. What we want to show is how to implement the configurability via properties.

initialize

The custom validator will retrieve the properties by name and set the min and max values upon initialization. This is what the dependency of type PropertyResolver is for. Notice that the method getRequiredProperty(String, Class) is being used. It will not only retrieve the property value, it will also throw an exception if the property is not resolvable. If the property name is omitted, the min and max will default respectively to 0 and Integer.MAX_VALUE, the same defaults for the @Size constraint. Finally, it will be checked that both min and max are non negative and max is not lesser than min.

isValid

The validation is pretty simple: a null value will be considered valid, then it will be checked that the size of the CharSequence is between min and max. If the validation fails we’ll leverage Hibernate´s provider specific implementation of ConstraintValidatorContext to create a constraint violation. Such constraint violation will have the same message as if the violation was on the @Size constraint. Our custom constraint’s behaviour will thus be indistinguishable from the @Size constraint’s behaviour to an external observer.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
public class ConfigurableSizeCharSequenceValidator implements ConstraintValidator<ConfigurableSize, CharSequence> {

    private final PropertyResolver propertyResolver;
    private int min;
    private int max;

    @Autowired
    public ConfigurableSizeCharSequenceValidator(PropertyResolver propertyResolver) {
        this.propertyResolver = propertyResolver;
    }

    @Override
    public void initialize(ConfigurableSize configurableSize) {
        String minProperty = configurableSize.minProperty();
        String maxProperty = configurableSize.maxProperty();
        this.min = "".equals(minProperty) ? 0 :
                propertyResolver.getRequiredProperty(minProperty, Integer.class);
        this.max = "".equals(maxProperty) ? Integer.MAX_VALUE :
                propertyResolver.getRequiredProperty(maxProperty, Integer.class);
        validateParameters();
    }

    private void validateParameters() {
        if (this.min < 0) {
            throw new IllegalArgumentException("The min parameter cannot be negative.");
        } else if (this.max < 0) {
            throw new IllegalArgumentException("The max parameter cannot be negative.");
        } else if (this.max < this.min) {
            throw new IllegalArgumentException("The length cannot be negative.");
        }
    }

    @Override
    public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        } else {
            int length = value.length();
            boolean retVal = length >= this.min && length <= this.max;
            if (!retVal) {
                HibernateConstraintValidatorContext hibernateContext =
                        context.unwrap(HibernateConstraintValidatorContext.class);
                hibernateContext.addMessageParameter("min", this.min)
                        .addMessageParameter("max", this.max);
                hibernateContext.disableDefaultConstraintViolation();
                hibernateContext
                        .buildConstraintViolationWithTemplate("{javax.validation.constraints.Size.message}")
                        .addConstraintViolation();
            }
            return retVal;
        }
    }

}

Testing the validation

Let’s apply our @ConfigurableSize constraint to the fullName field in our Employee class in lieu of the @NotEmpty constraint. Since the field is mandatory, we need to add the @NotNull annotation too.

1 2 3 4 5 6 7 8 9 10 11 12 13
@Data
@Accessors(chain = true)
@UniqueSsn
public class Employee {

    private Long id;
    @NotNull
    @ConfigurableSize(minProperty = "employee.fullName.size.min", maxProperty = "employee.fullName.size.max")
    private String fullName;
    @NotEmpty
    private String socialSecurityNumber;

}

For the annotation to work, we need to define the employee.fullName.size.min and employee.fullName.size.max properties

employee.fullName.size.min=3
employee.fullName.size.max=128

or, for those who prefer YAML

employee:
  fullName:
    size:
      min: 3
      max: 128

We’ll test the expected properties’ values in our @SpringBootTest class.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
@SpringBootTest
class MockValidatorDependenciesApplicationTests {

    //lines omitted for brevity
    @Value("${employee.fullName.size.min}")
    private int fullNameMinSize;
    @Value("${employee.fullName.size.max}")
    private int fullNameMaxSize;
    //lines omitted for brevity
    @Test
    void fullNameMinSizeHasExpectedValue() {
        assertEquals(3, fullNameMinSize);
    }

    @Test
    void fullNameMaxSizeHasExpectedValue() {
        assertEquals(128, fullNameMaxSize);
    }

}

To test the validation we need to mock the PropertyResolver dependency. We’ve shown in a previous post how to unit test the validation layer when our custom validators have dependencies.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
class EmployeeServiceValidationTest {
    //lines omitted for brevity
    private final EmployeeValidationService mockEmployeeValidationService = mock(EmployeeValidationService.class);
    private final PropertyResolver mockPropertyResolver = mock(PropertyResolver.class);
    private final List<ConstraintValidator<?, ?>> customConstraintValidators =
            Arrays.asList(
                    new UniqueSsnValidator(mockEmployeeValidationService),
                    new ConfigurableSizeCharSequenceValidator(mockPropertyResolver));
    //lines omitted for brevity

    void setupMocks() {
        //lines omitted for brevity
        fullNameSizeMustBeBetween(3, 128);
    }
    //lines omitted for brevity

    @SuppressWarnings("SameParameterValue")
    private void fullNameSizeMustBeBetween(int min, int max) {
        when(mockPropertyResolver.getRequiredProperty(eq("employee.fullName.size.min"), eq(Integer.class)))
                .thenReturn(min);
        when(mockPropertyResolver.getRequiredProperty(eq("employee.fullName.size.max"), eq(Integer.class)))
                .thenReturn(max);
    }

    @BeforeEach
    void setupTest() {
        resetEmployeeSequence();
        initEmployees();
        setupMocks();
    }
    //lines omitted for brevity
}

Let’s create an employee with a full name shorter than 3 characters and an employee with a full name longer than 128 characters. Then we’ll create one test case for each employee.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
class EmployeeServiceValidationTest {
    //lines omitted for brevity
    private final static Employee BAD_EMPLOYEE_FULL_NAME_TOO_SHORT = new Employee();
    private final static Employee BAD_EMPLOYEE_FULL_NAME_TOO_LONG = new Employee();
    //lines omitted for brevity
    private static void initBadEmployeeFullNameTooShort() {
        BAD_EMPLOYEE_FULL_NAME_TOO_SHORT.setId(null).setFullName("Jo").setSocialSecurityNumber(VALID_SSN);
    }

    private static void initBadEmployeeFullNameTooLong() {
        BAD_EMPLOYEE_FULL_NAME_TOO_LONG.setId(null)
                .setFullName("Daenerys Stormborn of the House Targaryen, First of Her Name, the Unburnt, Queen of " +
                        "the Andals and the First Men, Khaleesi of the Great Grass Sea, Breaker of Chains, " +
                        "and Mother of Dragons")
                .setSocialSecurityNumber(VALID_SSN);
    }
    //lines omitted for brevity
    private static void initEmployees() {
        //lines omitted for brevity
        initBadEmployeeFullNameTooShort();
        initBadEmployeeFullNameTooLong();
        //lines omitted for brevity
    }
    //lines omitted for brevity
    @Test
    void givenEmployeeWithFullNameTooShortWhenSaveThenCve() {
        ConstraintViolationException cve = assertThrows(ConstraintViolationException.class,
                () -> validationProxy.save(BAD_EMPLOYEE_FULL_NAME_TOO_SHORT));
        Set<ConstraintViolation<?>> violations = cve.getConstraintViolations();
        assertEquals(1, violations.size());
        assertEquals(ConfigurableSize.class,
                getFirstConstraintViolation(violations).getConstraintDescriptor().getAnnotation().annotationType());
    }

    @Test
    void givenEmployeeWithFullNameTooLongWhenSaveThenCve() {
        ConstraintViolationException cve = assertThrows(ConstraintViolationException.class,
                () -> validationProxy.save(BAD_EMPLOYEE_FULL_NAME_TOO_LONG));
        Set<ConstraintViolation<?>> violations = cve.getConstraintViolations();
        assertEquals(1, violations.size());
        assertEquals(ConfigurableSize.class,
                getFirstConstraintViolation(violations).getConstraintDescriptor().getAnnotation().annotationType());
    }
    //lines omitted for brevity
}

If we run our tests we’ll see that they are all green. We can now configure the employees’ full name minimum and maximum size via configuration properties!

Conclusion

We showed how to easily configure validators via properties in a Spring Boot project.

It’s possible to leverage the flexibility of Java Validation API’s custom validators and Spring’s dependency injection to configure our constraints via application properties.

We can create custom constraints that behave exactly like the standard constraints, but on top of that offer the advantage of external configurability. We’ll be able to modify the validator’s behaviour just by updating the application’s properties, without the need to modify our code and redeploy our application.

In our example we created a custom constraint called @ConfigurableSize that mimics the @Size constraint. Of course we can do the same for any standard validation constraint whose behaviour varies based on parameter values, like @Pattern, @Digits, @Min, @Max and so on.

The example project is available for download on GitLab.

2 thoughts on “Easily configure validators via properties in a Spring Boot project

  1. Great tutorial. I tried to implement a custom validator based on properties by following your tutorial, unfortunately i get the following error:

    HV000064 Unable to instantiate ConstraintValidator
    Caused by: java.lang.NoSuchMethodException ConfigurableSizeCharSequenceValidator.()

    So I added the no argument constructor and I got a null pointer exception:
    Cannot invoke “org.springframework.core.env.PropertyResolver.getRequiredProperty(String, java.lang.Class)” because “this.propertyResolver” is null

    1. Thank you.

      Did you get the error while running the unit tests? In that case the problem might be in the Validator factory. You need to create a custom Validator factory that will instantiate your custom constraint validators and inject the mocked dependencies in them. The solution is described with more in-depth details in this previous post (which illustrates an earlier version of this tutorial project): https://codemadeclear.com/index.php/2021/01/26/how-to-mock-dependencies-when-unit-testing-custom-validators/#solution

      If you got the error while executing the application (with the whole Spring context running) then it might be some issue with the Spring Boot autoconfiguration. You might want to check if you have all the necessary dependencies in your pom.xml as shown here: https://codemadeclear.com/index.php/2021/01/26/how-to-mock-dependencies-when-unit-testing-custom-validators/#project

      Hope this helps to solve your problem. Let me know.

Comments are closed.