We used to be very conservative on the constraints included in Hibernate Validator but we changed this policy recently and we would like to have more constraints built-in.
Of course, we won’t accept everything: the proposed constraints needs to be of general usage and well defined but the idea is to have more features in Hibernate Validator out of the box.
This post will be useful to those who have some interesting custom Bean Validation constraints and want to share them with the community.
There are a couple of nice benefits when constraints are part of Hibernate Validator rather than standalone ones:
-
All the Hibernate Validator users can benefit from these constraints. They are well documented and easy to find.
-
These constraints are maintained by the whole team and follow the evolution of Hibernate Validator.
-
They get Hibernate Validator Annotation Processor support. This annotation processor can scan classes during compilation and provide warnings/errors when constraint annotations are used inappropriately. This helps to spot possible problems at compilation and avoid runtime errors.
-
A programmatic constraint definition is available for constraints which are part of the engine.
-
Last but definitely not least, it becomes possible for such constraints to get additional required functionality on the engine level, such as Hibernate Validator’s
ScriptEvaluator
to evaluate a script which is required by@ScriptAssert
and@ParameterScriptAssert
, or some additional configuration property like the newly introducedtemporalValidationTolerance
which is used in temporal constraints when comparing temporal instances.
For the purposes of this post, a new constraint annotation to check if a given String
is a valid ISBN
will be created.
This very constraint has now been included into Hibernate Validator.
Step 1 - Add constraint annotation and validator
Commit: https://github.com/hibernate/hibernate-validator/commit/5ba6668ef0725db718d98a9f34cb9cd4bcaa4a3e
The first main step is to create the constraint itself with corresponding validators and register them in the engine.
Add constraint annotation
In order to do this, first a new constraint annotation should be created. In our case it will be called
@ISBN
and have one attribute of its own, type
, which would define if the ISBN under inspection is
ISBN 10 or ISBN 13.
/**
* Checks that the annotated character sequence is a valid
* <a href="https://en.wikipedia.org/wiki/International_Standard_Book_Number">ISBN</a>.
* The length of the number and the check digit are both verified.
* <p>
* The supported type is {@code CharSequence}. {@code null} is considered valid.
* <p>
* During validation all non ISBN characters are ignored. All digits and 'X' are considered
* to be valid ISBN characters. This is useful when validating ISBN with dashes separating
* parts of the number (ex. {@code 978-161-729-045-9}).
*
* @author Marko Bekhta
* @since 6.0.6
*/
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
public @interface ISBN {
String message() default "{org.hibernate.validator.constraints.ISBN.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
Type type() default Type.ISBN13;
/**
* Defines several {@code @ISBN} annotations on the same element.
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface List {
ISBN[] value();
}
/**
* Defines the ISBN length. Valid lengths of ISBNs are {@code 10} and {@code 13}
* which are represented as {@link Type#ISBN10} and {@link Type#ISBN13} correspondingly.
*/
enum Type {
ISBN10,
ISBN13
}
}
Looking at this code snippet, it is worth emphasizing the following:
-
An empty array as
validatedBy
parameter in@Constraint
. There is no need to declare any validators here as they will be registered in the engine directly. -
Key for default message is in the format
{org.hibernate.validator.constraints.${nameOfConstraint}.message}
, where${nameOfConstraint}
should be replaced with the same string as the name of constraint. -
If applicable, a
@Repeatable
annotation should be defined. -
New constraints are added to
org.hibernate.validator.constraints
package or its subpackages. -
It is important to add a javadoc describing what kind of checks are performed by the constraint as well as to which types it is applicable.
-
A
@since
tag should specify the version in which we introduce the constraint.
Add constraint validator
Having the annotation in place, corresponding validators can now be implemented. ConstraintValidator
interface should be implemented for each pair of constraint and applicable type. In case of @ISBN
,
it is only CharSequence
. As this will be a constraint specific to Hibernate Validator, its
validators should be placed in org.hibernate.validator.internal.constraintvalidators.hv
package.
A possible implementation for ISBNValidator
can be the following:
public class ISBNValidator implements ConstraintValidator<ISBN, CharSequence> {
/**
* Pattern to replace all non ISBN characters. ISBN can have digits or 'X'.
*/
private static Pattern NON_ISBN_CHARACTERS = Pattern.compile( "[^\\dX]" );
private int length;
private Function<String, Boolean> checkChecksumFunction;
@Override
public void initialize(ISBN constraintAnnotation) {
switch ( constraintAnnotation.type() ) {
case ISBN10:
length = 10;
checkChecksumFunction = this::checkChecksumISBN10;
break;
case ISBN13:
length = 13;
checkChecksumFunction = this::checkChecksumISBN13;
break;
}
}
@Override
public boolean isValid(CharSequence isbn, ConstraintValidatorContext context) {
if ( isbn == null ) {
return true;
}
// Replace all non-digit (or !=X) chars
String digits = NON_ISBN_CHARACTERS.matcher( isbn ).replaceAll( "" );
// Check if the length of resulting string matches the expecting one
if ( digits.length() != length ) {
return false;
}
return checkChecksumFunction.apply( digits );
}
// check algorithm details are omitted here.
}
There is really no difference between constraint validators implemented as part of the engine, or the ones implemented outside of it. For more details on constraint validator implementation see the documentation.
Register the validator
Now that the validator implementation is in place, it should be registered somehow. When a constraint is
part of the engine, there is no need to declare it in the validatedBy
attribute or to use the ServiceLoader
mechanism as described in this post.
Instead it should be registered directly in the ConstraintHelper
constructor by adding the following line:
putConstraint( tmpConstraints, ISBN.class, ISBNValidator.class );
It is preferred to keep these declarations of available validators in alphabetical order of constraints.
In the case of the ISBN
example, there is only one validator, but it is also possible to register
multiple ones for the same constraint, using the ConstraintHelper#putConstraints()
method as follows:
putConstraints( tmpConstraints, ISBN.class, Arrays.asList(
ISBNValidatorForCharacterSequence.class,
ISBNValidatorForSomeOtherClass.class,
....
ISBNValidatorForSomeAnotherClass.class
) );
You can see it in action here.
Add a default message to the resource bundle
Message keys in ValidationMessages.properties
are kept in alphabetical order within groups.
A default message (in English) must always be added:
org.hibernate.validator.constraints.Email.message ...
org.hibernate.validator.constraints.ISBN.message = invalid ISBN number
org.hibernate.validator.constraints.Length.message ...
It is also much appreciated when a translation is added to other language files, if a reliable one can be provided but it’s definitely not something blocking for your constraint inclusion.
Test it all
There should be two kinds of tests added for a new constraint. First, all constraint validator
implementations should be tested to make sure that the checks in them are giving the expected
results. These tests are added to the org.hibernate.validator.test.internal.constraintvalidators.hv
package. Both positive and negative scenarios should be present:
private ISBNValidator validator;
@BeforeMethod
public void setUp() throws Exception {
validator = new ISBNValidator();
}
@Test
public void validISBN10() throws Exception {
validator.initialize( initializeAnnotation( ISBN.Type.ISBN10 ) );
assertValidISBN( null );
assertValidISBN( "99921-58-10-7" );
assertValidISBN( "9971-5-0210-0" );
assertValidISBN( "960-425-059-0" );
assertValidISBN( "0-9752298-0-X" );
//... more positive cases
}
@Test
public void invalidISBN10() throws Exception {
validator.initialize( initializeAnnotation( ISBN.Type.ISBN10 ) );
// invalid check-digit
assertInvalidISBN( "99921-58-10-8" );
assertInvalidISBN( "9971-5-0210-1" );
assertInvalidISBN( "960-425-059-2" );
assertInvalidISBN( "80-902734-1-8" );
// ... more negative cases
// invalid length
assertInvalidISBN( "" );
assertInvalidISBN( "978-0-5" );
assertInvalidISBN( "978-0-55555555555555" );
// ... more negative cases
}
private ISBN initializeAnnotation(ISBN.Type type) {
ConstraintAnnotationDescriptor.Builder<ISBN> descriptorBuilder = new ConstraintAnnotationDescriptor.Builder<>( ISBN.class );
descriptorBuilder.setAttribute( "type", type );
return descriptorBuilder.build().getAnnotation();
}
Because the @ISBN
constraint has a type
attribute, behavior of which should be tested, and because
ConstraintValidator
requires an annotation to be passed to the initialize()
method,
the ConstraintAnnotationDescriptor.Builder
can be used to create an annotation proxy,
as shown in the initializeAnnotation()
method of the above example.
These tests ensure that the validators work correctly. But it is also required to make sure
that the new constraint and its validators are registered and picked up by the engine. This
second kind of tests is added to the org.hibernate.validator.test.constraints.annotations.hv
package and is extended from AbstractConstrainedTest
. A simple bean with the new constraint
applied to the allowed types and at the allowed places (field/method return value/etc.) should be
added as a private
static
class of that test. And the Validator#validate()
method should
be called with the instance of such bean as parameter, to make sure that new constraint works.
An example of such test for the @ISBN
constraint can be as follows:
public class ISBNConstrainedTest extends AbstractConstrainedTest {
@Test
public void testISBN() {
Foo foo = new Foo( "978-1-56619-909-4" );
Set<ConstraintViolation<Foo>> violations = validator.validate( foo );
assertNoViolations( violations );
}
@Test
public void testISBNInvalid() {
Foo foo = new Foo( "5412-3456-7890" );
Set<ConstraintViolation<Foo>> violations = validator.validate( foo );
assertThat( violations ).containsOnlyViolations(
violationOf( ISBN.class ).withMessage( "invalid ISBN number" )
);
}
private static class Foo {
@ISBN
private final String number;
public Foo(String number) {
this.number = number;
}
}
}
To make assertions on constraint violations, the ConstraintViolationAssert
assertion class
is used. It includes ConstraintViolationAssert#assertNoViolations()
which will check that the
passed set of constraint violations is empty.
It also has a ConstraintViolationAssert#assertThat()
method which receives a set of violations
and returns a ConstraintViolationSetAssert
which provides a rich API to perform assertions on violations.
For the purposes of the ConstrainedTest
, it is enough to check that the expected violations are present
with the expected message (see ISBNConstrainedTest#testISBNInvalid()
above).
Preferably, a @TestForIssue annotation should be added to all tests written for a
constraint. This annotation can either be applied to a test method or to a test class.
It has just one parameter - jiraKey which helps to link back a test to a corresponding
JIRA ticket (for example @TestForIssue(jiraKey = "HV-{number}") where {number} is the
number of the corresponding JIRA ticket).
|
Step 2 - Add programmatic definition
Commit: https://github.com/hibernate/hibernate-validator/commit/91c4ed35b04c9a6a98ba9038aee514f94618fc6b
The second main step is to add a programmatic definition for a new constraint. The definition
itself is added to the org.hibernate.validator.cfg.defs
package.
It should extend ConstraintDef
and provide methods which allow to specify all the
constraint-specific attributes:
public class ISBNDef extends ConstraintDef<ISBNDef, ISBN> {
public ISBNDef() {
super( ISBN.class );
}
public ISBNDef type(ISBN.Type type) {
addParameter( "type", type );
return this;
}
}
The names of the methods in YourConstraintDef
should match the names of the corresponding annotation
attributes.
The methods should also allow chaining so that constraint definition can be initialized
in one go, hence they should return this
.
The programmatic definition should also be tested. Based on its complexity and the amount of
tests needed, they can either be included into the validator test class (in case of the
ISBN
example - into ISBNValidatorTest
) or have their own test class within the same package.
A simple programmatic constraint test requires a bean to which new constraint can be applied and a test applying it:
@Test
public void testProgrammaticDefinition() throws Exception {
HibernateValidatorConfiguration config = getConfiguration( HibernateValidator.class );
ConstraintMapping mapping = config.createConstraintMapping();
mapping.type( Book.class )
.property( "isbn", FIELD )
.constraint( new ISBNDef().type( ISBN.Type.ISBN13 ) );
config.addMapping( mapping );
Validator validator = config.buildValidatorFactory().getValidator();
Set<ConstraintViolation<Book>> constraintViolations = validator.validate( new Book( "978-0-54560-495-6" ) );
assertNoViolations( constraintViolations );
constraintViolations = validator.validate( new Book( "978-0-54560-495-7" ) );
assertThat( constraintViolations ).containsOnlyViolations(
violationOf( ISBN.class )
);
}
private static class Book {
private final String isbn;
private Book(String isbn) {
this.isbn = isbn;
}
}
Step 3 - Add annotation processor support
Commit: https://github.com/hibernate/hibernate-validator/commit/5134f1265f932ccd63fc761221e747ae5d2df03e
In the third step, a new constraint should also be registered within the annotation processor types:
-
First, the new constraint should be added to
TypeNames.HibernateValidatorTypes
in alphabetical order:
public static final String EMAIL = ....
public static final String ISBN = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".ISBN";
public static final String LENGTH = ....
-
Then this constraint, together with all types to which it can be applied, should be registered in
ConstraintHelper
constructor of the annotation processor:
registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.EMAIL, ....
registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.ISBN, CharSequence.class );
registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.LENGTH, ....
-
To make sure that constraint is registered correctly, a simple test should be added to
ConstraintValidationProcessorTest
:
/**
* Simple bean that has both correct and incorrect usages of ISBN constraint.
*/
public class ModelWithISBNConstraints {
@ISBN private String string;
@ISBN private CharSequence charSequence;
@ISBN private Integer integer;
}
public class ConstraintValidationProcessorTest extends ConstraintValidationProcessorTestBase {
// ...
@Test
public void isbnConstraints() {
File[] sourceFiles = new File[] {
compilerHelper.getSourceFile( ModelWithISBNConstraints.class )
};
boolean compilationResult =
compilerHelper.compile( new ConstraintValidationProcessor(), diagnostics, false, true, sourceFiles );
assertFalse( compilationResult );
assertThatDiagnosticsMatch(
diagnostics,
new DiagnosticExpectation( Kind.ERROR, 22 )
);
}
}
Step 4 - Add documentation
Commit: https://github.com/hibernate/hibernate-validator/commit/c7cc895f176fae499fe1fd21e3561e94c7e6bba6
Finally, to finish adding a new constraint to Hibernate Validator, documentation for this constraint should be added to the reference guide.
A new list item should be added in the second chapter under Additional constraints, similar to the following one:
`@ISBN`:: Checks that the annotated character sequence is a valid https://en.wikipedia.org/wiki/International_Standard_Book_Number[ISBN]. `type` determines the type of ISBN. The default is ISBN-13.
Supported data types::: `CharSequence`
Hibernate metadata impact::: None
It should describe the purpose of the constraint, what can be specified by constraint attributes, and to which types it can be applied. List of additional constraints is ordered alphabetically.
Conclusion
The complete code used throughout this post as an example is available on Github.
This post provided step by step instructions for adding a new constraint to Hibernate Validator:
One more thing, not mentioned in the post. Although this isn’t strictly necessary, it is recommended discussing the addition of new constraints before jumping right to their implementation. The discussion could be carried out either at our JIRA (in a new ticket created for the specific constraint in mind) or via the mailing list. |