Search code examples
javabean-validationhibernate-validator

How to register custom ConstraintMapping for validations defined in validation-constraints.xml


TLDR: I wanted separate custom bean validation definition and its ConstraintValidator implementations in separate modules. To do that, I have to manually register using ConstraintMapping. It works for annotated classes. But defined binding are not shared/available for validations defined via validation-constraints.xml. How to fix? I tried to debug it, to find out, where it's initialized and why it's problem, but initialization of these is far from easy.

Motivations:

I) separated module: not to enforce users of API to bring ConstraintValidator if they don't want to.

II) usage of validation-constraint.xml: if you have to use java classes, which you cannot modify. Used as xml-defined "mixin".

EDIT: better implementation

as @Guillaume Smet pointed out (Thanks!) all of my implementation can be greatly simplified., as bean validation supports registering using service loader. So you literary don't need anything I described below — custom mapping creation, registering, even Validator/LocalValidatorFactoryBean registering. The autoconfigured ones will work the same. I verified, that service loader config is picked up (remove all my stuff, tried without config, then add it and retried, it works now). Yet the situation is the same When we mention in validation-constraints.xml annotation, which was registered using service loader, we get exception, that give ConstraintValidator does not exist. Annotated classes works.

EDIT: validation classes

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {})
@SupportedValidationTarget(ValidationTarget.ANNOTATED_ELEMENT)
@ReportAsSingleViolation
public @interface Iso8601Timestamp {
    String message() default "must have format for example '1970-01-01T01:00:00.000+01:00[CET]', " +
            "but '${validatedValue}' was provided. Timestamp is parsed using {parsingStrategy} strategy.";

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

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

    ParsingStrategy parsingStrategy() default ParsingStrategy.ISO_OFFSET_DATE_TIME;

    enum ParsingStrategy {
        STRICT,
        ISO_OFFSET_DATE_TIME,

    }
}

and

public class Iso8601TimestampValidator implements ConstraintValidator<Iso8601Timestamp, Object> {

    private Iso8601Timestamp.ParsingStrategy parsingStrategy;

    @Override
    public void initialize(Iso8601Timestamp constraint) {
        parsingStrategy = Objects.requireNonNull(constraint.parsingStrategy());
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        //...
    }
}

Implementation:

Since I had to use @Constraint(validatedBy = {}) to avoid compile-time dependency, annotation-ConstraintValidator bindings are externalized into file, and read into:

@Data
public static class Mapping {
    Class annotation;
    Class validator;
}

Now methods, which will create Validator instance. But it works the same with LocalValidatorFactoryBean, and whether I customize it further with MessageInterpolator and ConstraintValidatorFactory or I don't touch it at all.

public static Validator getValidator(List<CustomValidator.Mapping> mappings) {
    return getValidationConfiguration(mappings).buildValidatorFactory().getValidator();
}

private static Configuration<?> getValidationConfiguration(List<CustomValidator.Mapping> mappings) {
    GenericBootstrap genericBootstrap = Validation.byDefaultProvider();
    Configuration<?> configuration = genericBootstrap.configure();
    if (!(configuration instanceof HibernateValidatorConfiguration)) {
        throw new CampException("HibernateValidatorConfiguration is required.");
    }

    registerMappings((HibernateValidatorConfiguration) configuration, mappings);
    return configuration;
}

private static void registerMappings(HibernateValidatorConfiguration hibernateValidatorConfiguration,
                                     List<CustomValidator.Mapping> mappings) {
    mappings.stream()
            .map(pair -> creteConstraintMapping(hibernateValidatorConfiguration, pair))
            .forEach(hibernateValidatorConfiguration::addMapping);
}

@SuppressWarnings("unchecked")
private static ConstraintMapping creteConstraintMapping(HibernateValidatorConfiguration hibernateValidatorConfiguration,
                                                        CustomValidator.Mapping pair) {
    ConstraintMapping constraintMapping = hibernateValidatorConfiguration.createConstraintMapping();
    constraintMapping
            .constraintDefinition(pair.getAnnotation())
            .includeExistingValidators(false)
            .validatedBy(pair.getValidator());

    return constraintMapping;
}

This is our TestInstance:

@Data
@AllArgsConstructor
public class TestInstance {
    @Iso8601Timestamp
    public CharSequence timestamp;

    public String text;
}

And everything will just work, but if we create validation-constraints.xml file and put following validation definition into it(package names removed), everything goes sideways. Also <class ignore-annotations="false"/> is ignored. Value of this element is ignored, annotations on TestInstance are ignored.

<constraint-mappings
    xmlns="http://jboss.org/xml/ns/javax/validation/mapping"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://jboss.org/xml/ns/javax/validation/mapping
                    validation-mapping-1.1.xsd"
    version="1.1">

  <bean class="TestInstance">
    <class ignore-annotations="false"/>
    <getter name="timestamp" ignore-annotations="true">
      <constraint annotation="Iso8601Timestamp"/>
    </getter>
    <getter name="text" ignore-annotations="true">
      <constraint annotation="javax.validation.constraints.NotBlank"/>
    </getter>
  </bean>
</constraint-mappings>

With this we will get following exception when we validate instance of TestInstance(bad naming):

javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint '….Iso8601Timestamp' validating type 'java.lang.CharSequence'. Check configuration for 'timestamp'

But it's not a problem with validation-constraints.xml per se. It's fine. Provided NotBlank works like charm. Only the custom validations which requires manual binding, here represented by Iso8601Timestamp, does not work.

Workaround for validation-constraints.xml — don't use it. Remap data from unmodifiable file to file you own, or just don't validate and let it fail, or … depends on your options.

But I'd really like to know, why this is a problem in first place, since the mapping was provided. Is there some different initialization needed to be made if validation-constraints.xml is (also) used?


Solution

  • You can declare constraint validators that are in external libraries using the service loader. That is definitely the way to go.

    Typically, you would add the name of your constraint validators in a META-INF/services/javax.validation.ConstraintValidator file located in your library.

    This way, as soon as your library is in the classpath, the constraint validators in it are automatically declared to Hibernate Validator.

    You have a full article explaining all this on our Hibernate blog: https://in.relation.to/2017/03/02/adding-custom-constraint-definitions-via-the-java-service-loader/ .