1. Introduction
Well-designed software components should always be designed according to a contract of what is required of inputs and outputs; constraints; or pre-conditions and post-conditions. Validation of inputs and outputs need to be performed at component boundaries. These conditions need to be well-advertised but ideally the checking of these conditions should not overwhelm the functional aspects of the code.
public PersonPocDTO createPOC(PersonPocDTO personDTO) {
if (personDTO==null) {
throw new BadRequestException("createPOC.person: must not be null");
} else if (StringUtils.isNotBlank(personDTO.getId())) {
throw new InvalidInputException("createPOC.person.id: must be null");
... (1)
1 | business logic possibly overwhelmed by validation concerns and actual checks |
This lecture will introduce working with the Bean Validation API to implement declarative and expressive validation.
@Validated(PocValidationGroups.CreatePlusDefault.class)
public PersonPocDTO createPOC(
@NotNull
@Valid PersonPocDTO personDTO); (1)
1 | conditions well-advertised and isolated from target business logic |
1.1. Goals
The student will learn:
-
to add declarative pre-conditions and post-conditions to components using the Bean Validation API
-
to define declarative validation constraints
-
to implement custom validation constraints
-
to enable injected call validation for components
-
to identify patterns/anti-patterns for validation
1.2. Objectives
At the conclusion of this lecture and related exercises, the student will be able to:
-
add Bean Validation to their project
-
add declarative data validation constraints to types and method parameters
-
configure a
ValidatorFactory
and obtain aValidator
-
programmatically validate an object
-
programmatically validate parameters to and response from a method call
-
inspect constraint violations
-
enable Spring/AOP validation for components
-
implement a custom validation constraint
-
implement a cross-parameter validation constraint
-
configure Web API constraint violation responses
-
configure Web API parameter validation
-
configure JPA validation
-
configure Spring Data Mongo Validation
-
identify some patterns/anti-patterns for using validation
2. Background
Bean Validation is a standard that originally came out of Java EE/SE as JSR-330 (1.0) in the 2009 timeframe and later updated with JSR-349 (1.1) in 2013 and JSR-380 (2.0) in 2017. It was meant to simplify validation — reducing the chance of error and to reduce the clutter of validation within the business code that required validation. The standard is not specific any particular tier (e.g., UI, Web, Service, DB) but has been integrated into several of the individual frameworks. [1]
Implementations include:
Hibernate Validator was the original and current reference implementation and used within Spring Boot today.
3. Dependencies
To get started with validation in Spring Boot — we add a dependency on spring-boot-starter-validation
.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
That will bring in the validation reference implementation from Hibernate and an implementation for regular expression validation constraints.
[INFO] +- org.springframework.boot:spring-boot-starter-validation:jar:2.7.0:compile
[INFO] | +- org.apache.tomcat.embed:tomcat-embed-el:jar:9.0.63:compile (3)
[INFO] | \- org.hibernate.validator:hibernate-validator:jar:6.2.3.Final:compile (2)
[INFO] | +- jakarta.validation:jakarta.validation-api:jar:2.0.2:compile (1)
[INFO] | +- org.jboss.logging:jboss-logging:jar:3.4.3.Final:compile
[INFO] | \- com.fasterxml:classmate:jar:1.5.1:compile
1 | overall Bean Validation API |
2 | Bean Validation API reference implementation from Hibernate |
3 | regular expression implementation for regular expression constraints |
4. Declarative Constraints
At the core of the Bean Validation API are declarative constraint annotations we can place directly into the source code.
4.1. Data Constraints
The following snippet shows a class with a property that is required to be not-null when valid.
import javax.validation.constraints.NotNull;
...
class AClass {
@NotNull
private String aValue;
...
Constraints do not Actively Prevent Invalid State
The constraint does not actively prevent the property from being set to an invalid value. Unlike with the Lombok annotations, no class code is written as a result of the validation annotations. The constraint will identify whether the property is currently valid when validated. The validating caller can decide what to do with the invalid state. |
4.2. Common Built-in Constraints
You can find a list of built-in constraints in the Bean Validation Spec and the Hibernate Validator documentation. A few common ones include:
|
|
Additional constraints are provided by:
-
Hibernate Additional Constraints (e.g., @CreditCardNumber)
-
Java Bean Validation Extension (JBVExt) (e.g. @Alpha, @IsDate, @IPv4)
We will take a look at how to create a custom constraint later in this lecture.
4.3. Method Constraints
We can provide pre-condition and post-condition constraints on methods and constructors.
The following snippet shows a method that requires a non-null and valid parameter and will return a non-null result.
Constraints for input are placed on the individual input parameters.
Constraints on the output (as well as cross-parameter constraints) are placed on the method.
The @Validated
annotation is added to components to trigger Spring to enable validation for injected components.
import javax.validation.Valid;
import org.springframework.validation.annotation.Validated;
...
@Component
@Validated (3)
public class AService {
@NotNull (2)
public String aMethod(@NotNull @Valid AClass aParameter) { (1)
return ...;
}
1 | method requires a non-null parameter with valid content |
2 | the result of the method is required to be non-null |
3 | @Validated triggers Spring’s use of the Bean Validation API to validate the call |
Null Properties Are Considered @Valid Unless Explicitly Constrained with @NotNull
It is a best practice to consider a null value as valid unless explicitly constrained with @NotNull .
|
We will eventually show all this integrated within Spring, but first we want to make sure we understand the plumbing and what Spring is doing under the covers.
5. Programmatic Validation
To work with the Bean Validation API directly, our initial goal is to obtain a standard javax.validation.Validator
instance.
import javax.validation.Validator;
...
Validator validator;
This can be obtained manually or through injection.
5.1. Manual Validator Instantiation
We can obtain a Validator using one of the Validation
builder methods to return a ValidatorFactory
.
The following snippet shows the builder providing an instance of the default factory provider, with the default configuration.
We will come back to the configure()
method later.
If we have no configuration changes, we can simplify with a call to buildDefaultValidatorFactory()
.
The Validator
instance is obtained from the ValidatorFactory
.
Both the factory and validator instances are thread-safe.
import javax.validation.Validation;
...
ValidatorFactory myValidatorFactory = Validation.byDefaultProvider()
.configure()
//configuration commands
.buildValidatorFactory(); (1)
//ValidatorFactory myValidatorFactory = Validation.buildDefaultValidatorFactory();
Validator myValidator = myValidatorFactory.getValidator(); (1)
1 | factory and validator instances are thread-safe, initialized during bean construction, and used during instance methods |
5.2. Inject Validator Instance
With the validation starter dependency comes a default Validator. For components, we can simply have it injected.
@Autowired
private Validator validator;
5.3. Customizing Injected Instance
If we want the best of both worlds and have some customizations to make, we can define a @Bean
factory to replace the AutoConfigure and return our version of the Validator
instead.
@Bean (1)
public Validator validator() {
return Validation.byDefaultProvider()
.configure()
//configuration commands
.buildValidatorFactory()
.getValidator();
}
1 | A custom Validator @Bean within the application will override the default provided by Spring Boot |
5.4. Review: Class with Constraint
The following validation example(s) will use the following class with a non-null constraint on one of its properties.
@Getter
public class AClass {
@NotNull
private String aValue;
...
5.5. Validate Object
The most straight forward use of the validation programmatic API is to validate individual objects.
The object to be validated is supplied and a Set<ConstraintViolation>
is returned.
No exceptions are thrown by the Bean Validation API itself for constraint violations.
Exceptions are only thrown for invalid use of the API and to report violations within frameworks like
Contexts and Dependency Injection (CDI) or Spring Boot.
The following snippet shows an example of using the validator to validate an object with at least one constraint error.
//given - @NotNull aProperty set to null by ctor
AClass invalidAClass = new AClass();
//when - checking constraints
Set<ConstraintViolation<AClass>> violations = myValidator.validate(invalidAClass);(1)
violations.stream().forEach(v-> log.info("field name={}, value={}, violated={}",
v.getPropertyPath(), v.getInvalidValue(), v.getMessage()));
//then - there will be at least one violation
then(violations).isNotEmpty(); (2)
1 | programmatic call to validate object |
2 | non-empty return set means violations where found |
The result of the validation is a Set<ConstraintViolation>
.
Each constraint violation identifies the:
|
|
The following shows the output of the example.
field name=aValue, value=null, violated=must not be null
Specific Property Validation
We can also validate a value against the definition of a specific property
|
5.6. Validate Method Calls
We can also validate calls to and results from methods (and constructors too). This is commonly performed by AOP code — rather than anything closely related to the business logic.
The following snippet shows a class with a method that has input and response constraints.
The input parameter must be valid and not null.
The response must also be not null.
A @Valid
constraint on an input argument or response will trigger the object validation — which we just demonstrated — to be performed.
public class AService {
@NotNull
public String aMethod(@NotNull @Valid AClass aParameter) { (1) (2)
return ...
}
1 | @NotNull constrains aParameter to always be non-null |
2 | @Valid triggers validation contents of aParameter |
With those validation rules in place, we can check them for the following sample call.
//given
AService myService = new AService(); (1)
AClass myValue = new AClass();
//when
String result = myService.aMethod(myValue);
1 | Note: Service shown here as POJO. Must be injected for container to intercept and subject to validation |
Please note that the code above is a plain POJO call. Validation is only automatically performed for injected components. We will use this call to describe how to programmatically validate a method call. |
5.7. Identify Method Using Java Reflection
Before we can validate anything, we must identify the descriptors of the call and resolve a Method
reference using Java Reflection.
In the following example snippet we locate the method called aMethod
on the AService
class that accepts one parameter of AClass
type.
Object[] methodParams = new Object[]{ myValue };
Class<?>[] methodParamTypes = new Class<?>[]{ AClass.class };
Method methodToCall = AService.class.getMethod("aMethod", methodParamTypes);
The code above has now resolved a reference to the following method call
(AService)myService).aMethod((AClass)myValue);
5.8. Programmatically Check for Parameter Violations
Without actually making the call, we can check whether the given parameters violate defined method constraints by accessing the ExecutableValidator
from the Validator
object.
Executable
is a generalized java.lang.reflect
type for Method
and Constructor
.
//when
Set<ConstraintViolation<AService>> violations = validator
.forExecutables() (1)
.validateParameters(myService, methodToCall, methodParams);
1 | returns ExecutableValidator |
The following snippet shows the reporting of the validation results when subjecting our myValue
parameter to the defined validation rules of the aMethod()
method.
//then
then(violations).hasSize(1);
ConstraintViolation<?> violation = violations.iterator().next();
then(violation.getPropertyPath().toString()).isEqualTo("aMethod.arg0.aValue");
then(violation.getMessage()).isEqualTo("must not be null");
then(violation.getInvalidValue()).isEqualTo(null);
then(violation.getInvalidValue()).isEqualTo(myValue.getAValue());
5.9. Validate Method Results
We can also validate what is returned against the defined rules of the aMethod()
method using the same service instance and method reflection references from the parameter validation.
Except in this case, methodToCall
has already been called and we are now holding onto the result value.
The following example shows an example of validating a null result against the return rules of the aMethod()
method.
//given
String nullResult = null;
//when
violations = validator.forExecutables()
.validateReturnValue(myService, methodToCall, nullResult);
Since null is not allowed, one violation is reported.
//then
then(violations).hasSize(1);
violation = violations.iterator().next();
then(violation.getPropertyPath().toString()).isEqualTo("aMethod.<return value>");
then(violation.getMessage()).isEqualTo("must not be null");
then(violation.getInvalidValue()).isEqualTo(nullResult);
6. Method Parameter Naming
Validation is able to easily gather meaningful field path information from classes and properties.
When we validated the AClass
instance, we were told the given name of the property in error supplied from reflection.
class AClass {
@NotNull
private String aValue;
...
field name=aValue, value=null, violated=must not be null
However, reflection by default does not provide the given names of parameters — only the position.
public class AService {
@NotNull
public String aMethod(@NotNull @Valid AClass aParameter) {
return ...
}
[ERROR] SelfDeclaredValidatorTest.method_arguments:96
expected: "aMethod.aParameter.aValue"
but was: "aMethod.arg0.aValue"
1 | By default, argument position supplied (arg0 ) — not argument name |
There are two ways to solve this.
6.1. Add -parameters to Java Compiler Command
The first way to solve this would be to add the -parameter
option to the Java compiler.
The following snippet shows how to do this for the maven-compiler-plugin
.
Note that this only applies to what is compiled with Maven and not what is actively worked on within the IDE.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
The above appears to work fine Maven compiler plugin 3.10.1, but I encountered issues getting that working with the older 3.8.1 (without an explicit -parameters in compilerArgs ).
|
6.2. Add Custom ParameterNameProvider
Another way to help provide parameter names is to configure the ValidatorFactory
with a ParameterNameProvider
.
ValidatorFactory myValidatorFactory = Validation.byDefaultProvider()
.configure()
.parameterNameProvider(new MyParameterNameProvider()) (1)
.buildValidatorFactory();
1 | configuring ValidatorFactory with custom parameter name provider |
6.3. ParameterNameProvider
The following snippets shows the skeletal structure of a sample ParameterNameProvider
.
It has separate incoming calls for Method
and Constructor
calls and must produce a list of names to use.
This particular example is simply returning the default.
Example work will be supplied next.
import javax.validation.ParameterNameProvider;
import java.lang.reflect.Constructor;
import java.lang.reflect.Executable;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
...
public class MyParameterNameProvider implements ParameterNameProvider {
@Override
public List<String> getParameterNames(Constructor<?> ctor) {
return getParameterNames((Executable) ctor);
}
@Override
public List<String> getParameterNames(Method method) {
return getParameterNames((Executable) method);
}
protected List<String> getParameterNames(Executable method) {
List<String> argNames = new ArrayList<>(method.getParameterCount());
for (Parameter p: method.getParameters()) {
//do something to determine Parameter p's desired name
String argName=p.getName(); (1)
argNames.add(argName);
}
return argNames; (2)
}
}
1 | real work to determine the parameter name goes here |
2 | must return a list of parameter names of expected size |
6.4. Named Parameters
The Bean Validation API does not provide a way to annotate parameters with names.
They left that up to us and other Java standards.
In this example, I am making use of javax.inject.Named
to supply a textual name of my choice.
import javax.inject.Named;
...
private static class AService {
@NotNull
public String aMethod(@NotNull @Valid @Named("aParameter") AClass aParameter) {(1)
return ...
}
1 | @Named annotation is providing a name to use for MyParameterNameProvider |
6.5. Determining Parameter Name
Now we can update MyParameterNameProvider
to look for and use the @Named.value
property if provided or default to the name from reflection.
Named named = p.getAnnotation(Named.class);
String argName=named!=null && StringUtils.isNotBlank(named.value()) ?
argName=named.value() : //@Named.property
p.getName(); //default reflection name
argNames.add(argName);
The result is a property path that possibly has more meaning.
original: aMethod.arg0.aValue
assisted: aMethod.aParameter.aValue
7. Graphs
Constraint validation has the ability to follow a graph of references annotated with @Valid
.
The following snippet shows an example set of parent classes — each with a reference to equivalent child instances.
The child instance will be invalid in both cases.
Only one of the child references is annotated with @Valid
.
class Child {
@NotNull
String cannotBeNull; (1)
}
class NonTraversingParent {
Child child = new Child(); (2)
}
class TraversingParent {
@Valid (4)
Child child = new Child(); (3)
}
1 | child attribute constrained to not be null |
2 | child instantiated with default instance but not annotated |
3 | child instantiated with default instance |
4 | annotation instructs validator to traverse to the child and validate |
7.1. Graph Non-Traversal
We know from the previous chapter that we can validate any constraints on an object by passing the instance to the validate()
method.
However, validation will stop there if there are no @Valid
annotations on references.
The following snippet shows an example of a parent with an invalid child, but due to the lack of @Valid
annotation, the child state is not evaluated with the parent.
//given
Object nonTraversing = new NonTraversingParent(); (1)
//when
Set<ConstraintViolation<Object>> violations = validator.validate(nonTraversing); (2)
//then
then(violations).isEmpty(); (3)
1 | parent contains an invalid child |
2 | constraint validation does not traverse from parent to child |
3 | child errors are not reported because they were never checked |
7.2. Graph Traversal
Adding the @Valid
annotation to an object reference activates traversal to and validation of the child instance.
This can be continued to grandchildren with follow-on child @Valid
annotations.
The following snippet shows an example of a parent who’s validation traverses to the child because of the @Valid
annotation.
import javax.validation.Valid;
...
//given
Object traversing = new TraversingParent(); (1)
//when
Set<ConstraintViolation<Object>> violations = validator.validate(traversing); (2)
//then
String errorMsgs = violations.stream()
.map(v->v.getPropertyPath().toString()+":"+v.getMessage())
.collect(Collectors.joining("\n"));
then(errorMsgs).contains("child.cannotBeNull:must not be null"); (3)
then(violations).hasSize(1);
1 | parent contains an invalid child |
2 | constraint validation traverses relationship and performed on parent and child |
3 | child errors reported |
8. Groups
The Bean Validation API supports validation within different contexts using groups.
This allows us to write constraints for specific situations, use them when appropriate, and bypass them when not pertinent.
The earlier examples all used the default javax.validation.groups.Default
group and were evaluated by default because no groups were specified in the call to validate()
.
We can defined our own custom groups using interfaces.
8.1. Custom Validation Groups
The following snippet shows an example of two groups.
Create
should only be applied during creation.
CreatePlusDefault
should only be applied during creation but will also apply default validation.
UpdatePlusDefault
can be used to denote constraints unique to updates.
import javax.validation.groups.Default;
...
public interface PocValidationGroups { (3)
public interface Create{}; (1)
public interface CreatePlusDefault extends Create, Default{}; (2)
public interface UpdatePlusDefault extends Default{};
1 | custom group name to be used during create |
2 | groups that extend another group have constrains for that group applied as well |
3 | outer interface not required, Used in example to make purpose and source of group obvious |
8.2. Applying Groups
We can assign specific groups to constraints individually.
In the following example,
-
@Null id
will only be validated when validating theCreate
orCreatePlusDefault
groups -
@Past dob
will be validated for bothCreatePlusDefault
andDefault
validation -
@Size contactPoints
and@NotNull contactPoints
will each be validated the same as@Past dob
. The default group isDefault
when left unspecified.
public class PersonPocDTO {
@Null(groups = PocValidationGroups.Create.class, (1)
message = "cannot be specified for create")
private String id;
private String firstName;
private String lastName;
@Past(groups = Default.class) (2)
private LocalDate dob;
@Size(min=1, message = "must have at least one contact point") (3)
private List<@NotNull @Valid ContactPointDTO> contactPoints;
1 | explicitly setting group to Create , which does not include Default |
2 | explicitly setting group to Default |
3 | implicitly setting group to Default |
8.3. Skipping Groups
With use case-specific groups assigned, we can have certain defined constraints ignored.
The following example shows the validation of an object.
It has an assigned id
, which would make it invalid for a create.
However, there are no violations reported because the group for the @Null id
constraint was not validated.
//given
ContactPointDTO invalidForCreate = contactDTOFactory.make(ContactDTOFactory.oneUpId); (1)
//when
Set<ConstraintViolation<ContactPointDTO>> violations = validator.validate(invalidForCreate); (2)
//then
then(violations).hasSize(0);
1 | object contains non-null id , which is invalid for create scenarios |
2 | implicitly validating against the default group. Create group constraints not validated |
Can Redefine Default Group for Type with @GroupSequence
The Bean Validation API makes it possible to redefined the default group for a particular type using a |
8.4. Applying Groups
To apply a non-default group to the validation — we can simply add their interface identifiers in a sequence after the object passed to validate()
.
The following snippet shows an example of the CreatePlusDefault
group being applied.
The @Null id
constraint is validated and reported in error because the group is was assigned to was part of the validation command.
//given
...
String expectedError="id:cannot be specified for create";
//when
violations = validator.validate(invalidForCreate, CreatePlusDefault.class); (1)
//then
then(violations).hasSize(1); (2)
then(errors(violations)).contains(expectedError); (3)
1 | validating both the CreatePlusDefault and Default groups |
2 | @Null id violation detected and reported |
3 | errors() is a local helper method written to extract field and text from violation |
9. Multiple Groups
We have two ways of treating multiple groups
- validate all
-
performed by passing more than one group to the
validate()
method. Each group is validated in a non-deterministic manner - short circuit
-
performed by defining a
@GroupSequence
. Each group is validated in order and the sequence is short-circuited when their is a failure.
9.1. Example Class with Different Groups
The following snippet shows an example of a class with validations that perform at different costs.
-
@Size email
is thought to be simple to validate -
@Email email
is thought to be a more detailed validation -
the remaining validations have not been addressed by this classification
public class ContactPointDTO {
@Null (groups = {Create.class},
message = "cannot be specified for create")
private String id;
@NotNull
private String name;
@Size(min=7, max=40, groups= SimplePlusDefault.class) (1)
@Email(groups = DetailedOnly.class) (2)
private String email;
1 | @Size email is thought to be a cheap sanity check, but overly simplistic |
2 | @Email email is thought to be thorough validation, but only worth it for reasonably sane values |
The following snippet shows the groups being used in this example.
public interface Create{};
public interface SimplePlusDefault extends Default {}
public interface DetailedOnly {}
9.2. Validate All Supplied Groups
When groups are passed to validate in a sequence, all groups in that sequence are validated.
The following snippet shows an example with SimplePlusDefault
and DetailedOnly
supplied to validate()
.
Each group will be validated, no matter what the results are.
String nameErr="name:must not be null"; //Default Group
String sizeErr="email:size must be between 7 and 40"; //Simple Group
String formatErr="email:must be a well-formed email address"; //DetailedOnly Group
//when - validating against all groups
Set<ConstraintViolation<ContactPointDTO>> violations = validator.validate(
invalidContact,
SimplePlusDefault.class, DetailedOnly.class);
//then - all groups will have their violations reported
then(errors(violations)).contains(nameErr, sizeErr, formatErr).hasSize(3); (1) (2) (3)
1 | @NotNull name (nameError ) is part of Default group |
2 | @Size email (sizeError ) is part of SimplePlusDefault group |
3 | @Email email (formatError ) is part of DetailedOnly group |
9.3. Short-Circuit Validation
If we instead want to layer validations such that cheap validations come first and more extensive or expensive validations occur only after earlier groups are successful, we can define a @GroupSequence
.
-
Groups earlier in the sequence are performed first.
-
Groups later in the sequence are performed later — but only if all constraints in earlier groups pass. Validation will short-circuit at the individual group level when applying a sequence.
The following snippet shows an example of defining a @GroupSequence
that lists the order of group validation.
@GroupSequence({ SimplePlusDefault.class, DetailedOnly.class }) (1)
public interface DetailOrder {};
1 | defines an order-list of validation groups to apply |
The following example shows how the validation stopped at the SimplePlusDefault
group and did not advance to the DetailedOnly
group.
//when - validating using a @GroupSequence
violations = validator.validate(invalidContact, DetailOrder.class);
//then - validation stops once a group produces a violation
then(errors(violations)).contains(nameErr, sizeErr).hasSize(2); (1)
1 | validation was short-circuited at the group where the first set of errors detected |
9.4. Override Default Group
The @GroupSequence` annotation can be directly applied to a type to override the default group when validating instances of that class. |
10. Spring Integration
We saw earlier how we could programmatically validate constraints for Java methods.
This capability was not intended for business code to call — but rather for calls to be intercepted by AOP and constraints applied by that intercepting code.
We can annotate @Component
classes or interfaces with constraints and have Spring perform that validation role for us.
The following snippet shows an example of a interface with a simple aCall
method that accepts an int
parameter that must be greater than 5.
All the information on the method call should be familiar to you by now.
Only the @Validated
annotation is new.
The @Validated
annotation triggers Spring AOP to apply Bean Validation to calls made on the type (interface or class).
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.Min;
@Validated (2)
public interface ValidatedComponent {
void aCall(@Min(5) int mustBeGE5); (1)
}
1 | interface defines constraint for parameter(s) |
2 | @Validated triggers Spring to perform method validation on component calls |
10.1. Validated Component
The following snippet shows a class implementation of the interface and further declared as a @Component
.
Therefore it can be injected and method calls subject to container interpose using AOP interception.
@Component (1)
public class ValidatedComponentImpl implements ValidatedComponent {
@Override
public void aCall(int mustBeGE5) {
}
}
1 | designates this class implementation to be used for injection |
The component is injected into clients.
@Autowired
private ValidatedComponent component;
10.2. ConstraintViolationException
With the component injected, we can have parameters and results validated against constraints.
The following snippet shows an example component call where a call is made with an invalid parameter.
Spring performs the method validation, throws a javax.validation.ConstraintViolationException
,
and prevents the call.
The Set<ConstraintViolation>
can be obtained from the exception.
At that point we have returned to some familiar territory we covered with programmatic validation.
import javax.validation.ConstraintViolationException;
...
//when
ConstraintViolationException ex = catchThrowableOfType(
() -> component.aCall(1), (1)
ConstraintViolationException.class);
//then
Set<ConstraintViolation<?>> violations = ex.getConstraintViolations();
String errorMsgs = violations.stream()
.map(v->v.getPropertyPath().toString() +":" + v.getMessage())
.collect(Collectors.joining("\n"));
then(errorMsgs).isEqualTo("aCall.mustBeGE5:must be greater than or equal to 5");
1 | Spring intercepts the component call, detects violations, and reports using exception |
10.3. Successful Validation
Of course, if we pass valid parameter(s) to the method —
-
the parameters are validated against the method constraints
-
no exception is thrown
-
the
@Component
method is invoked -
the return object is validated against declared constraints (none in this example)
assertThatNoException().isThrownBy(
()->component.aCall(10) (1)
);
1 | parameter value 10 satisfies the @Min(5) constraint — thus no exception |
10.4. Liskov Substitution Principle
One thing you may have noticed with the selected example is that the interface contained constraints and not the class declaration.
As a matter of fact, if we add any additional constraint beyond what the interface defined — we will get a ConstraintDeclarationException
thrown — preventing the call from completing.
The Bean Validation Specification
describes it
as following the
Liskov Substitution Principle — where anything that is a sub-type of T
can be inserted in place of T
.
Said more specific to Bean Validation — a sub-type or implementation class method cannot add more restrictive constraints to call.
@Component
public class ValidatedComponentImpl implements ValidatedComponent {
@Override
public void aCall(@Positive int mustBeGE5) {} //invalid (1)
}
1 | Bean Validation enforces that subtypes cannot be more constraining than their interface or parent type(s) |
javax.validation.ConstraintDeclarationException: HV000151: A method overriding another method must not redefine the parameter constraint configuration, but method ValidatedComponentImpl#aCall(int) redefines the configuration of ValidatedComponent#aCall(int).
10.5. Disabling Parameter Constraint Override
For the Hibernate Validator, the constraint override rule can be turned off during factory configuration. You can find other Hibernate-specific features in the Hibernate Validator Specifics section of the on-line documentation.
The snippet below uses a generic property interface to disable parameter override constraint.
return Validation.byDefaultProvider() (1)
.configure()
.addProperty("hibernate.validator.allow_parameter_constraint_override",
Boolean.TRUE.toString()) (2)
.parameterNameProvider(new MyParameterNameProvider())
.buildValidatorFactory()
.getValidator();
1 | generic factory configuration interface used to initialize factory |
2 | generic property interface used to set custom behavior of Hibernate Validator |
The snippet below uses a Hibernate-specific configurer and custom method to disable parameter override constraint.
return Validation.byProvider(HibernateValidator.class) (1)
.configure()
.allowOverridingMethodAlterParameterConstraint(true) (2)
.parameterNameProvider(new MyParameterNameProvider())
.buildValidatorFactory()
.getValidator();
1 | Hibernate-specific configuration interface used to initialize factory |
2 | Hibernate-specific method used to set custom behavior of Hibernate Validator |
10.6. Spring Validated Group(s)
We saw earlier how we could programmatically validate using explicit validation groups.
Spring uses the @Validated
annotation in a dual role to define that as well.
-
@Validated
on the interface/class triggers validation to occur -
@Validated
on a parameter or method causes the validation to apply the identified group(s)-
the
groups
attribute is used for this purpose
-
//@Validated (1)
public interface PocService {
@NotNull
@Validated(CreatePlusDefault.class) (2)
public PersonPocDTO createPOC(
@NotNull (3)
@Valid PersonPocDTO personDTO); (4)
1 | @Validated at the class/interface/component level triggers validation to be performed |
2 | @Validated at the method level used to apply specific validation groups (CreatePlusDefault ) |
3 | @NotNull at the property level requires personDTO to be supplied |
4 | @Valid at the property level triggered personDTO to be validated |
10.7. Spring Validated Group(s) Example
The following snippet shows an example of a class where the id
property is required to be null when validating against the Create
group.
public class PersonPocDTO {
@Null(groups = Create.class, message = "cannot be specified for create")
private String id; (1)
1 | id must be null only when validating against Create group |
The following snippet shows the constrained method being passed a parameter that is illegal for the Create
constraint group.
A ConstraintViolationException
is thrown with violations.
PersonPocDTO pocWithId = pocFactory.make(oneUpId); (3)
assertThatThrownBy(() -> pocService.createPOC(pocWithId)) (1)
.isInstanceOf(ConstraintViolationException.class)
.hasMessageContaining("createPOC.person.id: cannot be specified for create"); (2)
1 | @Validated on component triggered validation to occur |
2 | @Validated(CreatePlusDefault.class) caused Create and Default rules to be validated |
3 | poc instance created with an id assigned — making it invalid |
11. Custom Validation
Earlier I listed several common, built-in constraints and available library constraints. Hopefully, they provide most or all of what is necessary to meet our validation needs — but there is always going to be that need for custom validation.
The snippet below shows an example of a custom validation being applied to a LocalDate
— that validates the value is of a certain age in years, with an optional timezone offset.
public class ValidatedClass {
@MinAge(age = 16, tzOffsetHours = -4)
private LocalDate dob;
11.1. Constraint Interface Definition
We can start with the interface definition for our custom constraint annotation.
@Documented
@Target({ ElementType.METHOD, FIELD, ANNOTATION_TYPE, PARAMETER, TYPE_USE })
@Retention( RetentionPolicy.RUNTIME )
@Repeatable(value= MinAge.List.class)
@Constraint(validatedBy = {
MinAgeLocalDateValidator.class,
MinAgeDateValidator.class
})
public @interface MinAge {
String message() default "age below minimum({age}) age";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int age() default 0;
int tzOffsetHours() default 0;
@Documented
@Retention(RUNTIME)
@Target({ METHOD, FIELD, ANNOTATION_TYPE, PARAMETER, TYPE_PARAMETER })
public @interface List {
MinAge[] value();
}
}
11.2. @Documented Annotation
The @Documented
annotation instructs the Javadoc processing to include the Javadoc for this annotation within the Javadoc output for the classes that use it.
/**
* Defines a minimum age based upon a LocalDate, the current
* LocalDate, and a specified timezone.
*/
@Documented //include this in Javadoc for elements that it is defined
The following images show the impact made to Javadoc for a different @PersonHasName
annotation example.
Not only are the constraints shown for the class but the documentation for the annotations is included in the produced Javadoc.
11.3. @Target Annotation
The @Target
annotation defines locations where the constraint is legally allowed to be applied.
The following table lists examples of the different target types.
ElementType.FIELD
define validation on a Java attribute within a class |
ElementType.METHOD
define validation on a return type or cross-parameters of a method |
||
ElementType.PARAMETER
define validation on a parameter to a method |
ElementType.TYPE_USE
define validation within a parameterized type |
||
ElementType.TYPE
define validation on an interface or class that likely inspects the state of the type |
ElementType.CONSTRUCTOR
define validation on the resulting instance after constructor completes |
||
ElementType.ANNOTATION_TYPE
This type allows other annotations to be defined based on this annotation. The snippet shows an example of constraint |
11.4. @Retention
@Retention
is used to determine the lifetime of the annotation.
@Retention(
//SOURCE - annotation discarded by compiler
//CLASS - annotation available in class file but not loaded at runtime - default
RetentionPolicy.RUNTIME //annotation available thru reflection at runtime
)
Bean Validation should always use RUNTIME
11.5. @Repeatable
The @Repeatable
annotation and declaration of an annotation wrapper class is required to supply annotations multiple times on the same target.
This is normally used in conjunction with different validation groups.
The @Repeatable.value
specifies an @interface that contains a value
method that returns an array of the annotation type.
The snippet below provides an example of the @Repeatable
portions of MinAge
.
@Repeatable(value= MinAge.List.class)
public @interface MinAge {
...
@Retention(RUNTIME)
@Target({ METHOD, FIELD, ANNOTATION_TYPE, PARAMETER, TYPE_USE })
public @interface List {
MinAge[] value();
}
}
The following snippet shows the annotation being applied multiple times to the same property — but assigned different groups.
@MinAge(age=18, groups = {VotingGroup.class})
@MinAge(age=65, groups = {RetiringGroup.class})
public LocalDate getConditionalDOB() {
return dob;
}
Repeatable Syntax Use Simplified
The requirement for the wrapper class is based on the Java requirement to have only one annotation type per target. Prior to Java 8, we were also required to explicitly use the construct in the code. Now it is applied behind the scenes by the compiler. Pre-Java 8 Use of Repeatable
|
11.6. @Constraint
The @Constraint
is used to identify the class(es) that will implement the constraint.
The annotation is not used for constraints built upon other constraints (e.g., @AdultAge
⇒ @MinAge
).
The annotation can specify multiple classes — one for each unique type the constraint can be applied to.
The following snippet shows two validation classes: one for java.util.Date
and the other for java.time.LocalDate
.
@Constraint(validatedBy = {
MinAgeLocalDateValidator.class, (1)
MinAgeDateValidator.class (2)
})
public @interface MinAge {
1 | validates annotated LocalDate values |
2 | validates annotated Date values |
@MinAge(age=18, groups = {VotingGroup.class})
@MinAge(age=65, groups = {RetiringGroup.class})
public LocalDate getConditionalDOB() { (1)
return dob;
}
@MinAge(age=16, message="found java.util.Date age({age}) violation")
public Date getDobAsDate() { (2)
return Date.from(dob.atStartOfDay().toInstant(ZoneOffset.UTC));
}
1 | constraining type LocalDate |
2 | constraining type Date |
11.6.1. Core Constraint Annotation Properties
The core constraint annotation properties include
- message
-
contains the default error message template to be returned when constraint violated. The contents of the message get interpolated to fill in variables and substitute entire text strings. This provides a means for more detailed messages as well as internationalization of messages.
- groups
-
identifies which group(s) to validate this constraint against
- payload
-
used to supply instance-specific metadata to the validator. A common example is to establish a severity structure to instruct the validator how to react.
The following snippet provides an example declaration of core properties for @MinAge
constraint.
public @interface MinAge {
String message() default "age below minimum({age}) age";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
...
11.7. @MinAge-specific Properties
Each individual constraint annotation can also define its own unique properties. These values will be expressed in the target code and made available to the constraining code at runtime.
The following example shows the @MinAge
constraint with two additional properties
-
age - defines how old the subject has to be in years to be valid
-
tzOffsetHours - an example property demonstrating we can have as many as we need
public @interface MinAge {
...
int age() default 0;
int tzOffsetHours() default 0;
...
11.8. Constraint Implementation Class
The annotation referenced zero or more constraint implementation classes — differentiated by the Java type they can process.
@Constraint(validatedBy = {
MinAgeLocalDateValidator.class,
MinAgeDateValidator.class
})
public @interface MinAge {
Each implementation class has two methods they can override.
-
initialize()
accepts the specific annotation instance that will be validated against -
isValid()
accepts the value to be validated and a context for this specific call. The minimal job of this method is to return true or false. It can optionally provide additional or custom details using the context.
11.9. Constraint Implementation Type Examples
The following snippets show the @MinAge
constraint being implemented against two different temporal types: java.time.LocalDate
and java.util.Date
.
We, of course, could have used inheritance to simplify the implementation.
public class MinAgeLocalDateValidator implements ConstraintValidator<MinAge, LocalDate> {
...
@Override
public void initialize(MinAge annotation) { ... }
@Override
public boolean isValid(LocalDate dob, ConstraintValidatorContext context) { ... }
public class MinAgeDateValidator implements ConstraintValidator<MinAge, Date> {
...
@Override
public void initialize(MinAge annotation) { ... }
@Override
public boolean isValid(Date dob, ConstraintValidatorContext context) { ... }
11.10. Constraint Initialization
The constraint initialize provides a chance to validate whether the constraint definition is valid on its own.
An invalid constraint definition is reported using a RuntimeException
.
If an exception is thrown during either the initialize()
or isValid()
method, it will be
wrapped in a ValidationException
before being reported to the application.
public class MinAgeLocalDateValidator implements ConstraintValidator<MinAge, LocalDate> {
private int minAge;
private ZoneOffset zoneOffset;
@Override
public void initialize(MinAge annotation) {
if (annotation.age() < 0) {
throw new IllegalArgumentException("age constraint cannot be negative");
}
this.minAge = annotation.age();
if (annotation.tzOffsetHours() > 23 || annotation.tzOffsetHours() < -23) {
throw new IllegalArgumentException("tzOffsetHours must be between -23 and +23");
}
zoneOffset = ZoneOffset.ofHours(annotation.tzOffsetHours());
}
11.11. Constraint Validation
The isValid()
method is required to return a boolean true or false — to indicate whether the value is valid according to the constraint.
It is a best-practice to only validate non-null values and to independently use @NotNull
to enforce a required value.
The following snippet shows a simple evaluation of whether the expressed LocalDate
value is older than the minimum required age
.
@Override
public boolean isValid(LocalDate dob, ConstraintValidatorContext context) {
if (dob!=null) { //assume null is valid and use @NotNull if it should not be
final LocalDate now = LocalDate.now(zoneOffset);
final int currentAge = Period.between(dob, now).getYears();
return currentAge >= minAge;
}
return true;
}
}
Treat Null Values as Valid
Null values should be considered valid and independently constrained by |
11.12. Custom Violation Messages
I won’t go into any detail here, but will point out that the isValid()
method has the opportunity to either augment or replace the constraint violation messages reported.
The following example is from a cross-parameter constraint and is reporting that parameters 1 and 2 are not valid when used together in a method call.
context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
.addParameterNode(1)
.addConstraintViolation()
.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
.addParameterNode(2)
.addConstraintViolation();
//the following removes default-generated message
//context.disableDefaultConstraintViolation(); (1)
1 | make this call to eliminate default message |
The following shows the default constraint message provided in the target code.
@ConsistentNameParameters(message = "name1 and/or name2 must be supplied") (1)
public NamedThing(String id, String name1, String name2, LocalDate dob) {
1 | @ConsistentNameParameters is a cross-parameter validation constraint validating name1 and name2 |
NamedThing.name1:name1 and/or name2 must be supplied (1)
NamedThing.name2:name1 and/or name2 must be supplied (1)
NamedThing.<cross-parameter>:name1 and/or name2 must be supplied (2)
1 | path/message generated by the custom constraint validator |
2 | default path/message generated by validation framework |
12. Cross-Parameter Validation
Custom validation is useful but often times the customization is necessary for when we need to validate two or more parameters used together.
The following snippet shows an example of two parameters — name1 and name2 — with the requirement that at least one be supplied. One or the other can be null — but not both.
class NamedThing {
@ConsistentNameParameters(message = "name1 and/or name2 must be supplied") (1)
public NamedThing(String id, String name1, String name2, LocalDate dob) {
1 | cross-parameter annotation placed on the method |
12.1. Cross-Parameter Annotation
The cross-parameter constraint will likely only apply to a method or constructor, so the number of @Targets
will be more limited.
Other than that — the differences are not yet apparent.
@Documented
@Constraint(validatedBy = ConsistentNameParameters.ConsistentNameParametersValidator.class )
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.RUNTIME)
public @interface ConsistentNameParameters {
12.2. @SupportedValidationTarget
Because of the ambiguity when annotating a method, we need to apply the @SupportedValidationTarget
annotation to identify whether the validation is for the parameters going into the method or the response from the method.
-
ValidationTarget.PARAMETERS - parameters to method
-
ValidationTarget.ANNOTATED_ELEMENT - returned element from method
@SupportedValidationTarget(ValidationTarget.PARAMETERS) (1)
public class ConsistentNameParametersValidator
implements ConstraintValidator<ConsistentNameParameters, Object[]> { (2)
1 | declaring that we are validating parameters going into method/ctor |
2 | must accept Object[] that will be populated with actual parameters |
@SupportValidationTarget adds Clarity to Annotation Purpose
Think how the framework would be confused without the |
12.3. Method Call Correctness Validation
Funny - within the work of a validation method, it sometimes needs to validate whether it is being called correctly. Was the constraint annotation applied to a method with the wrong signature? Did — somehow — a parameter of the wrong type end up in an unexpected position?
The snippet below highlights the point that cross-parameter constraint validators are strongly tied to method signatures. They expect the parameters to be validated in a specific position in the array and to be of a specific type.
@Override
public boolean isValid(Object[] values, ConstraintValidatorContext context) { (1)
if (values.length != 4) { (2)
throw new IllegalArgumentException(
String.format("Unexpected method signature, 4 params expected, %d supplied", values.length));
}
for (int i=1; i<3; i++) { //look at positions 1 and 2 (3)
if (values[i]!=null && !(values[i] instanceof String)) {
throw new IllegalArgumentException(
String.format("Illegal method signature, param[%d], String expected, %s supplied", i, values[i].getClass()));
}
}
...
1 | method parameters supplied in Object[] |
2 | not a specific requirement for this validation — but sanity check we have what is expected |
3 | names validated must be of type String |
12.4. Constraint Validation
Once we have the constraint properly declared and call-correctness validated, the implementation will look similar to most other constraint validations. This method is required to return a true or false.
@Override
public boolean isValid(Object[] values, ConstraintValidatorContext context) { (1)
...
String name1= (String) values[1];
String name2= (String) values[2];
return (StringUtils.isNotBlank(name1) || StringUtils.isNotBlank(name2));
}
13. Web API Integration
13.1. Vanilla Spring/AOP Validation
From what we have learned in the previous chapters, we know that we should be able to annotate any @Component
class/interface — including a @RestController
— and have constraints validated.
I am going to refer to this as "Vanilla Spring/AOP Validation" because it is not unique to any component type.
The following snippet shows an example of the Web API @RestController
that validates parameters according to Create
and Default
Groups.
@Validated (1)
public class ContactsController {
...
@RequestMapping(path=CONTACTS_PATH,
method= RequestMethod.POST,
consumes={MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
produces={MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
@Validated(PocValidationGroups.CreatePlusDefault.class) (2)
public ResponseEntity<PersonPocDTO> createPOC(
@RequestBody
@Valid (3)
PersonPocDTO personDTO) {
...
1 | triggers validation for component |
2 | configures validator for method constraints |
3 | identifies constraint for parameter |
If we call this with an invalid personDTO
(relative to the Default or Create groups), we would expect to see validation fail and some sort of error response from the Web API.
13.2. ConstraintViolationException Not Handled
As expected — Spring will validate the constraints and throw a ConstrainViolationException
.
However, Spring Boot — out of the box — does not provide built-in exception advice for ConstraintViolationException
.
That will result in the caller receiving a 500/INTERNAL_SERVER_ERROR
status response with the default error reporting message.
It is understandable that would be the default since constraints can be technically validated and reported from all different levels of our application.
The exception could realistically be caused by a real internal server error.
However — the reported status does not always have to be generic and misleading.
> POST http://localhost:64153/api/contacts
{"id":"1","firstName":"Douglass","lastName":"Effertz","dob":[2011,6,14],"contactPoints":[{"id":null,"name":"Cell","email":"penni.kautzer@hotmail.com","phone":"(876) 285-7887 x1055","address":{"street":"69166 Angelo Landing","city":"Jaredshire","state":"IA","zip":"81764-6850"}}]}
< 500 INTERNAL_SERVER_ERROR Internal Server Error (1)
{ "url" : "http://localhost:53298/api/contacts",
"statusCode" : 500,
"statusName" : "INTERNAL_SERVER_ERROR",
"message" : "Unexpected Error",
"description" : "unexpected error executing request: javax.validation.ConstraintViolationException: createPOC.person.id: cannot be specified for create",
"timestamp" : "2021-07-01T14:58:48.777269Z" }
1 | INTERNAL_SERVER_ERROR status is mis-leading — cause is bad value provided by client |
The violation — at least in this case — was a bad value from the client.
The id
property cannot be assigned when attempting to create a contact.
Ideally — this would get reported as either a 400/BAD_REQUEST
or 422/UNPROCESSABLE_ENTITY
.
Both are 4xx/Client error status and will point to something the client needs to correct.
13.3. ConstraintViolationException Exception Advice
Assuming that the invalid value came from the client, we can map the unhandled ConstraintViolationException
to a 400/BAD_REQUEST
using (in this case) a global @RestControllerAdvice
.
The following snippet shows how we can take some of the code we have seen used in the JUnit tests to report validation details — and use that within an @ExceptionHandler
to extract the details and report as a 400/BAD_REQUEST
to the client.
import info.ejava.examples.common.web.BaseExceptionAdvice;
...
@RestControllerAdvice (1)
public class ExceptionAdvice extends BaseExceptionAdvice { (2)
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<MessageDTO> handle(ConstraintViolationException ex) {
String description = ex.getConstraintViolations().stream()
.map(v->v.getPropertyPath().toString() + ":" + v.getMessage())
.collect(Collectors.joining("\n"));
HttpStatus status = HttpStatus.BAD_REQUEST; (3)
return buildResponse(status, "Validation Error", description, (Instant)null);
}
1 | controller advice being applied globally to all controllers in the application context |
2 | extending a class of exception handlers and helper methods |
3 | hard-wiring the exception to a 400/BAD_REQUEST status |
13.4. ConstraintViolationException Mapping Result
The following snippet shows the Web API response to the client expressed as a 400/BAD_REQUEST
.
{ "url" : "http://localhost:53408/api/contacts",
"statusCode" : 400,
"statusName" : "BAD_REQUEST",
"message" : "Validation Error",
"description" : "createPOC.person.id: cannot be specified for create",
"timestamp" : "2021-07-01T15:10:59.037162Z" }
Converting from a 500/INTERNAL_SERVER_ERROR
to a 400/BAD_REQUEST
is the minimum of what we wanted (at least it is a Client Error status), but we can try to do better.
We understood what was requested — but could not process the payload as provided.
13.5. Controller Constraint Validation
To cause the violation to be mapped to a 422/UNPROCESSABLE_ENTITY
to better indicate the problem, we can activate validation within the controller framework itself versus the vanilla Spring/AOP validation.
The following snippet shows an example of the @RestController
identifying validation and specific validation groups as part of the Web API framework.
The @Validated
annotation is now being used on the Web API parameters.
@RequestMapping(path=CONTACTS_PATH,
method= RequestMethod.POST,
consumes={MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
produces={MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
//@Validated(PocValidationGroups.CreatePlusDefault.class) -- no longer needed (1)
public ResponseEntity<PersonPocDTO> createPOC(
@RequestBody
//@Valid -- replaced by @Validated (1)
@Validated(PocValidationGroups.CreatePlusDefault.class) (2)
PersonPocDTO personDTO) {
1 | vanilla Spring/AOP validation has been disabled |
2 | Web API-specific parameter validation has been enabled |
13.6. MethodArgumentNotValidException
Spring MVC will independently validate the @RequestBody
, @RequestParam
, and @PathVariable
constraints according to internal rules.
Spring will throw an org.springframework.web.bind.MethodArgumentNotValidException
exception when encountering a violation with the request body.
That exception is mapped — by default — to return a very terse 400/BAD_REQUEST
response.
The snippet below show an example response payload for the default MethodArgumentNotValidException
mapping.
< 400 BAD_REQUEST Bad Request
{"timestamp":"2021-07-01T15:24:44.464+00:00",
"status":400,
"error":"Bad Request",
"message":"",
"path":"/api/contacts"}
By default — we may want to be terse to avoid too much information leakage. However, in this case, let’s improve this.
13.7. MethodArgumentNotValidException Custom Mapping
Of course, we can change the behavior if desired using a custom exception handler.
The following snippet shows an example custom exception handler mapping MethodArgumentNotValidException
to a 422/UNPROCESSABLE_ENTITY
.
@RestControllerAdvice
public class ExceptionAdvice extends BaseExceptionAdvice {
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<MessageDTO> handle(ConstraintViolationException ex) { ... }
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<MessageDTO> handle(MethodArgumentNotValidException ex) { (1)
List<String> fieldMsgs = ex.getFieldErrors().stream() (2)
.map(e -> e.getObjectName()+"."+e.getField()+": "+e.getDefaultMessage())
.toList();
List<String> globalMsgs = ex.getGlobalErrors().stream() (3)
.map(e -> e.getObjectName() +": "+ e.getDefaultMessage())
.toList();
String description = Stream.concat(fieldMsgs.stream(), globalMsgs.stream())
.collect(Collectors.joining("\n"));
return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, "Validation Error",
description, (Instant)null);
}
1 | Spring MVC throws MethodArgumentNotValidException for @RequestBody violations |
2 | reports fields of objects in error |
3 | reports overall objects (e.g., cross-parameter violations) in error |
13.7.1. MethodArgumentNotValidException Custom Mapping Response
This results in the client receiving an HTTP status indicating the request was understood but the payload provided was invalid. The description is as terse or verbose as we want it to be.
{ "url" : "http://localhost:53818/api/contacts",
"statusCode" : 422,
"statusName" : "UNPROCESSABLE_ENTITY",
"message" : "Validation Error",
"description" : "personPocDTO.id: cannot be specified for create",
"timestamp" : "2021-07-01T15:38:48.045038Z" }
Can Also Supply Client Value if Permitted
The exception handler has access to the invalid value if security policy allows information like that to be in the response. Note that error messages tend to be placed into logs and logs can end up getting handled at a generic level. For example, you would not want an invalid partial but mostly correct SSN to be part of an error log. |
13.8. @PathVariable Validation
Note that the Web API maps the @RequestBody
constraint violations independently from the other parameter types.
The following snippet shows an example of validation constraints applied to @PathVariable
.
These are physically in the URI.
@RequestMapping(path= CONTACT_PATH,
method=RequestMethod.GET,
produces={MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public ResponseEntity<PersonPocDTO> getPOC(
@PathVariable(name="id")
@Pattern(regexp = "[0-9]+", message = "must be a number") (1)
String id) {
1 | validation here is thru vanilla Spring/AOP validation |
13.9. @PathVariable Validation Result
The Web API (using vanilla Spring/AOP validation here) throws a ConstraintViolationException
for @PathVariable
and @RequestParam
properties.
We can leverage the custom exception handler we already have in place to do a decent job reporting status.
The following snippet shows an example response that is being mapped to a 400/BAD_REQUEST
using our custom exception handler for ConstraintViolationException
.
400/BAD_REQUEST
seems appropriate because the id
path parameter is invalid garbage (1…34
) in this case.
> HTTP GET http://localhost:53918/api/contacts/1...34, headers={masked}
< BAD_REQUEST/400
{ "url" : "http://localhost:53918/api/contacts/1...34",
"statusCode" : 400,
"statusName" : "BAD_REQUEST",
"message" : "Validation Error",
"description" : "getPOC.id: must be a number",
"timestamp" : "2021-07-01T15:51:34.724036Z" }
Remember — if we did not have that custom exception handler in place for ConstraintViolationException
, the HTTP status would have been a 500/INTERNAL_SERVER_ERROR
.
< 500 INTERNAL_SERVER_ERROR Internal Server Error
{"timestamp":"2021-07-01T19:21:31.345+00:00","status":500,"error":"Internal Server Error","message":"","path":"/api/contacts/1...34"}
13.10. @RequestParam Validation
@RequestParam
validation follows the same pattern as @PathVariable
and gets reported using a ConstraintViolationException
.
@RequestMapping(path= EXAMPLE_CONTACTS_PATH,
method=RequestMethod.POST,
consumes={MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
produces={MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public ResponseEntity<PersonsPageDTO> findPocsByExample(
@RequestParam(value = "pageNumber", defaultValue = "0", required = false)
@PositiveOrZero
Integer pageNumber,
@RequestParam(value = "pageSize", required = false)
@Positive
Integer pageSize,
@RequestParam(value = "sort", required = false) String sortString,
@RequestBody PersonPocDTO probe) {
13.11. @RequestParam Validation Violation Response
The following snippet shows an example response for an invalid set of query parameters.
> POST http://localhost:53996/api/contacts/example?pageNumber=-1&pageSize=0
{ ... }
> BAD_REQUEST/400
{ "url" : "http://localhost:53996/api/contacts/example?pageNumber=-1&pageSize=0",(1) (2)
"statusCode" : 400,
"statusName" : "BAD_REQUEST",
"message" : "Validation Error",
"description" : "findPocsByExample.pageNumber: must be greater than or equal to 0\nfindPocsByExample.pageSize: must be greater than 0",
"timestamp" : "2021-07-01T15:55:44.089734Z" }
1 | pageNumber has an invalid negative value |
2 | pageSize has an invalid non-positive value |
13.12. Non-Client Errors
One thing you may notice with the previous examples is that every constraint violation was blamed on the client — whether it was bad server code calling internally or not.
As an example, lets have the API require that value
be non-negative.
A successful validation of that constraint will result in a service method call.
@RequestMapping(path = POSITIVE_OR_ZERO_PATH,
method=RequestMethod.GET,
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public ResponseEntity<?> positive(
@PositiveOrZero (1)
@RequestParam(name = "value") int value) {
PersonPocDTO resultDTO = contactsService.positiveOrZero(value); (2)
1 | @RequestParam validated |
2 | value from valid request passed to service method |
13.13. Service Method Error
The following snippet shows that the service call makes an obvious error by passing the value
to an internal component requiring the value to not be positive.
public class PocServiceImpl implements PocService {
...
public PersonPocDTO positiveOrZero(int value) {
//obviously an error!!
internalComponent.negativeOrZero(value);
...
The internal component leverages the Bean Validation by placing a @NegativeOrZero
constraint on the value
.
This is obviously going to fail when the value is ever non-zero.
@Component
@Validated
public class InternalComponent {
public void negativeOrZero(@NegativeOrZero int value) {
13.14. Violation Incorrectly Reported as Client Error
The snippet below shows an example response of the internal error. It is being blamed on the client — when it was actually an internal server error.
> GET http://localhost:54298/api/contacts/positiveOrZero?value=1
< 400 BAD_REQUEST Bad Request
{ "url":"http://localhost:54298/api/contacts/positiveOrZero?value=1",
"statusCode":400,
"statusName":"BAD_REQUEST",
"message":"Validation Error",
"description":"negativeOrZero.value: must be less than or equal to 0",
"timestamp":"2021-07-01T16:23:27.666154Z"}
13.15. Checking Violation Source
One thing we can do to determine the proper HTTP response status — is to inspect the source information of the violation.
The following snippet shows an example of inspecting the whether the violation was reported by a class annotated with @RestController
.
If from the API, then report the 400/BAD_REQUEST
as usual.
If not, report it as a 500/INTERNAL_SERVER_ERROR
.
If you remember — that was the original default behavior.
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<MessageDTO> handle(ConstraintViolationException ex) {
String description = ...
boolean isFromAPI = ex.getConstraintViolations().stream() (1)
.map(v -> v.getRootBean().getClass().getAnnotation(RestController.class))
.filter(a->a!=null)
.findFirst()
.orElse(null)!=null;
HttpStatus status = isFromAPI ?
HttpStatus.BAD_REQUEST : HttpStatus.INTERNAL_SERVER_ERROR;
return buildResponse(status, "Validation Error", description, (Instant)null);
}
1 | isFromAPI set to true if any of the violations came from component annotated with @RestController |
13.16. Internal Server Error Correctly Reported
The following snippet shows the response to the client when our exception handler detects that is is handling at least one violation generated from a class annotated with @RestController
.
{ "url" : "http://localhost:54434/api/contacts/positiveOrZero?value=1",
"statusCode" : 500,
"statusName" : "INTERNAL_SERVER_ERROR",
"message" : "Validation Error",
"description" : "negativeOrZero.value: must be less than or equal to 0",
"timestamp" : "2021-07-01T16:45:50.235724Z" }
Any Source of Constraint Violation May be used to Impact Behavior
There is no magic to using the |
13.17. Service-detected Client Errors
Assuming we do a thorough job validating all client inputs at the @RestController
level, we might be done.
However, what about the case where the client validation is pushed down to the @Service
components.
We would have to adjust our violation source inspection.
The following snippet shows an example of a service validating client requests using the same constraints as before — except this is in a lower-level component.
public interface PocService {
@NotNull
@Validated(PocValidationGroups.CreatePlusDefault.class)
public PersonPocDTO createPOC(
@NotNull
@Valid PersonPocDTO personDTO);
Without any changes, we get violations reported as 400/BAD_REQUEST
status — which as I stated in the beginning was "OK".
< 400 BAD_REQUEST Bad Request
{ "url" : "http://localhost:55168/api/contacts",
"statusCode" : 400,
"statusName" : "BAD_REQUEST",
"message" : "Validation Error",
"description" : "createPOC.person.id: cannot be specified for create",
"timestamp" : "2021-07-01T17:40:12.221497Z" }
I won’t try to improve the HTTP status using source annotations on the validating class. I have already shown how to do that. Lets try another technique.
13.18. Payload
One other option we have to is leverage the payload metadata in each annotation.
Payload
classes are interfaces extending javax.validation.Payload
that identify certain characteristics of the constraint.
public @interface Xxx {
String message() default "...";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { }; (1)
1 | Annotations can carry extra metadata in the payload property |
The snippet below shows an example of a Payload subtype that expresses the violation should be reported as a 500/INTERNAL_SERVICE_ERROR
.
public interface InternalError extends Payload {}
This payload information can be placed in constraints that are known to be validated by internal components.
@Component
@Validated
public class InternalComponent {
public void negativeOrZero(@NegativeOrZero(payload = InternalError.class) int value) {
13.19. Exception Handler Checking Payloads
The snippet below shows our generic, global advice factoring in whether the violation came from an annotation with a InternalError
in the payload.
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<MessageDTO> handle(ConstraintViolationException ex) {
String description = ...;
boolean isFromAPI = ...;
boolean isInternalError = isFromAPI ? false : (1)
ex.getConstraintViolations().stream()
.map(v -> v.getConstraintDescriptor().getPayload())
.filter(p-> p.contains(InternalError.class))
.findFirst()
.orElse(null)!=null;
HttpStatus status = isFromAPI || !isInternalError ?
HttpStatus.BAD_REQUEST : HttpStatus.INTERNAL_SERVER_ERROR;
return buildResponse(status, "Validation Error", description, (Instant)null);
}
1 | isInternalError set to true if any violations contain the InternalError payload |
13.20. Internal Violation Exception Handler Results
The following snippet shows an example of a constraint violation where none of the violations where assigned a payload with InternalError
.
The status is returned as 400/BAD_REQUEST
.
> POST http://localhost:55288/api/contacts
< 400/BAD_REQUEST
"url" : "http://localhost:55288/api/contacts",
"statusCode" : 400,
"statusName" : "BAD_REQUEST",
"message" : "Validation Error",
"description" : "createPOC.person.id: cannot be specified for create",
"timestamp" : "2021-07-01T17:56:23.080884Z"
}
The following snippet shows an example of a constraint violation where at least one of the violations were assigned a payload with InternalError
.
The client may not be able to make heads-or-tails out of the error message, but at least they would know it is something on the server-side to be corrected.
> GET http://localhost:57547/api/contacts/positiveOrZero?value=1
< INTERNAL_SERVER_ERROR/500
{ "url" : "http://localhost:57547/api/contacts/positiveOrZero?value=1",
"statusCode" : 500,
"statusName" : "INTERNAL_SERVER_ERROR",
"message" : "Validation Error",
"description" : "negativeOrZero.value: must be less than or equal to 0",
"timestamp" : "2021-07-01T20:25:05.188126Z" }
14. JPA Integration
Bean Validation is integrated into the JPA standard.
This can be used to validate entities mostly when created, updated, or deleted.
Although not part of the standard, it is also used by some providers to customize generated database schema with additional RDBMS constraints (e.g., @NotNull
, @Size
).
By default, the JPA provider will implement validation of the Default
group for all @Entity
classes during creation or update.
The following is a list of JPA properties that can be used to impact the behavior. They all need to be prefixed with spring.jpa.properties.
when using Spring Boot properties to set the value.
|
ability to control validation at a high level |
|
|
identify groups(s) validated prior to inserting new row |
|
|
identify group(s) validated prior to updating existing row |
|
|
identify group(s) validated prior to removing existing row |
|
15. Mongo Integration
A basic Bean Validation implementation is integrated into Spring Data Mongo.
It leverages event-specific callbacks from
AbstractMongoEventListener
, which is integrated into the Spring
ApplicationListener
framework.
There are no configuration settings and after you see the details — you will quickly realize that they mean to handle the most common case (validate the Default
group on save()
) and for us to implement the corner-cases.
The following snippet shows an example of activating the default MongoRepository validation.
import org.springframework.data.mongodb.core.mapping.event.ValidatingMongoEventListener;
...
@Configuration
public class MyMongoConfiguration {
@Bean
public ValidatingMongoEventListener mongoValidator(Validator validator) {
return new ValidatingMongoEventListener(validator);
}
}
15.1. Validating Saves
To demonstrate validation within the data tier, lets assume that our document class has a constraint for the dob
to be supplied.
@Document(collection = "pocs")
public class PersonPOC {
...
@NotNull
private LocalDate dob;
...
}
When we attempt to save a PersonPOC in the repository without a dob
, the following example shows that the Java source object is validated, a violation is detected, and a ConstraintViolationException
is thrown.
//given
PersonPOC noDobPOC = mapper.map(pocDTOFactory.make().withDob(null));
//when
assertThatThrownBy(() -> contactsRepository.save(noDobPOC))
.isInstanceOf(ConstraintViolationException.class)
.hasMessageContaining("dob: must not be null");
There is nothing more to it than that until we look into the implementation of ValidatingMongoEventListener
.
15.2. ValidatingMongoEventListener
ValidatingMongoEventListener
extends AbstractMongoEventListener
, has a Validator
from injection, and overrides a single event callback called onBeforeSave()
.
package org.springframework.data.mongodb.core.mapping.event;
...
public class ValidatingMongoEventListener extends AbstractMongoEventListener<Object> {
...
private final Validator validator;
@Override
public void onBeforeSave(BeforeSaveEvent<Object> event) {
...
}
}
It does not take much imagination to guess how the rest of this works. I have removed the debug code from the method and provided the remaining details here.
@Override
public void onBeforeSave(BeforeSaveEvent<Object> event) {
Set violations = validator.validate(event.getSource());
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
The onBeforeSaveEvent
is called after the source Java object has been converted to a form that is ready for storage.
15.3. Other AbstractMongoEventListener Events
There are many reasons — beyond validation (e.g., sub-document ID generation) — we can take advantage of the
AbstractMongoEventListener
callbacks, so it will be good to provide an overview of them now.
-
There are three core events: Save, Load, and Delete
-
Several possible stages to each core event
-
before action performed (e.g., delete)
-
and before converting between Java object and
Document
(e.g., save and load) -
and after converting between Java object and
Document
(e.g., save and load)
-
-
after action is complete (e.g., save)
-
The following table lists the specific events.
onApplicationEvent(MongoMappingEvent<?> event) |
general purpose event handler |
onBeforeConvert(BeforeConvertEvent<E> event) |
callback before Java object converted to |
onBeforeSave(BeforeSaveEvent<E> event) |
callback after Java object converted to |
onAfterSave(AfterSaveEvent<E> event) |
callback after |
onAfterLoad(AfterLoadEvent<E> event) |
callback after |
onAfterConvert(AfterConvertEvent<E> event) |
callback after |
onBeforeDelete(BeforeDeleteEvent<E> event) |
callback before document deleted from DB |
onAfterDelete(AfterDeleteEvent<E> event) |
callback after document deleted from DB |
15.4. MongoMappingEvent
The MongoMappingEvent
itself has three main items.
-
Collection Name — name of the target collection
-
Source — the source or target Java object
-
Document
— the source or targetbson
data type stored to the database
Our validation would always be against the source
, so we just need a callback that provides us with a read-only value to validate.
16. Patterns / Anti-Patterns
Every piece of software has an interface with some sort of pre-conditions and post-conditions that have some sort of formal or informal constraints. Constraint validation — whether using custom code or Bean Validation framework — is a decision to be made when forming the layers of a software architecture. The following patterns and anti-patterns list a few concerns to address. The original outline and content provided below is based on Tom Hombergs' Bean Validation Anti-Patterns article.
16.1. Data Tier Validation
The Data tier has long been the keeper of data constraints — especially with RDBMS schema.
-
Should the constraint validations discussed be implemented at that tier?
-
Can validation wait all the way to the point where it is being stored?
-
Should service and other higher levels of code be working with data that has not been validated?
16.1.1. Data Tier Validation Safety Checks
Hombergs' suggestion was to use the data tier validation as a safety check, but not the only layer. [2]
That, of course, makes a lot of sense since the data tier may not need to know what a valid e-mail looks like or (to go a bit further) what type of e-mail addresses we accept? However, the data tier will want to sanity check that required fields exist and may want to go as far as validating format if query implementations require the data to be in a specific form.
16.2. Use case-specific Validation
Re-use is commonly a goal in software development. However, as we saw with validation groups — some data types have use case-specific constraints.
The simple example is when |
Figure 3. Re-usable Data Class with Use case-Specific Semantics
|
As more use case-specific constraints pile up on re-usable classes they can get very cluttered and present a violation of single purpose Single-responsibility principle.
16.2.1. Separate Syntactic from Semantic Validation
Hombergs proposes we
-
use Bean Validation for syntactical validation for re-usable data classes
-
implement query methods in the data classes for semantic state and perform checks against that specific state within the use case-specific code. [2]
One way of implementing use case-specific query methods and have them leverage Bean Validation constraints and a re-used data type would be to create use case-specific decorators or wrappers.
Lombok’s experimental @Delegate
code generation may be of assistance here.
16.3. Anti: Validation Everywhere
It is likely for us to want to validate at the client interface (Web API) since these are very external inputs. It is also likely for us to want to validate at the service level because our service could be injected into multiple client interfaces. It is then likely that internal components see how easy it is to add validation triggers and add to the mix. At the end of the line — the persistence layer adds a final check.
In some cases, we can get the same information validated several times. We have already shown in the Bean Validation details earlier in this topic — the challenge it can be to determine what is a client versus internal issue when a violation occurs.
16.3.1. Establish Validation Architecture
Hombergs recommends having a clear validation strategy versus ad-hoc everywhere [2]
I agree with that strategy and like to have a clear dividing line of "once it reaches this point — data its valid". This is where I like to establish service entry points (validated) and internal components (sanity checked). Entry points check everything about the data. Internal components trust that the data given is valid and only need to verify if a programming error produced a null or some other common illegal value.
I also believe separating data types into external ("DTOs") and internal ("BOs") helps thin down the concerns. DTO classes would commonly be thorough and allow clients to know exactly what constraints exist. BO classes — used by the business and persistence logic only accept valid DTOs and should be done with detailed validation by the time they are mapped to BO classes.
16.3.2. Separating Persistence Concerns/Constraints
Hombergs went on to discuss a third tier of data types — persistence tier data types — separate from BOs as a way of separating persistence concerns away from BO data types. [3] This is part of implementing a Hexagonal Software Architecture where the core application has no dependency on any implementation details of the other tiers. This is more of a plain software architecture topic than specific to validation — but it does highlight how there can be different contexts for the same conceptual type of data processed.
17. Summary
In this module we learned:
-
to add Bean Validation dependencies to the project
-
to add declarative pre-conditions and post-conditions to components using the Bean Validation API
-
to define declarative validation constraints
-
to configure a
ValidatorFactory
and obtain aValidator
-
to programmatically validate an object
-
to programmatically validate parameters to and response from a method call
-
to enable Spring/AOP validation for components
-
to implement custom validation constraints
-
to implement a cross-parameter validation constraint
-
to configure Web API constraint violation responses
-
to configure Web API parameter validation
-
to identify patterns/anti-patterns for validation
-
to configure JPA validation
-
to configure Spring Data Mongo Validation
-
to identify some patterns/anti-patterns for using validation