Use annotation to define constraints on Spring REST API request payloads to return error message if any of the constraints is violated.

Configuration
Java Compilation:
Java Runtime:
Web Server:
Maven:
Java Compilation:
Java Runtime:
Web Server:
Gradle:
JDK 17.0.2
JRE OpenJDK 17.0.2
Tomcat 10.1.16
Maven 3.9.6
JDK 17.0.2
JRE OpenJDK 17.0.2
Tomcat 10.1.16
Gradle 8.6
Create the following folder structure:
folder structure of Spring REST API Constraint Validation with Hibernate Validator
pom.xml: (if using Gradle, replace pom.xml with build.gradle)
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>net.maxjava</groupId> <artifactId>SpringWebServiceValidation</artifactId> <version>1.0</version> <packaging>war</packaging> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>6.1.6</version> </dependency> <dependency> <groupId>org.hibernate.validator</groupId> <artifactId>hibernate-validator</artifactId> <version>8.0.1.Final</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.4</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> </dependency> </dependencies> <build> <plugins> <plugin> <artifactId>maven-compiler-plugin</artifactId> <version>3.13.0</version> <configuration> <source>17</source> <target>17</target> </configuration> </plugin> </plugins> </build> </project>
plugins { id 'java' id 'war' } java { sourceCompatibility = 17 targetCompatibility = 17 } group 'net.maxjava' version '1.0' repositories { mavenCentral() } dependencies { implementation group: 'org.springframework', name: 'spring-webmvc', version: '6.1.6' implementation group: 'org.hibernate', name: 'hibernate-validator', version: '8.0.1.Final' implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.15.4' compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30' annotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.30' }
Some notes about pom.xml:
  • spring-webmvc for necessary Spring REST controllers and its dependencies
  • lombok to remove boilerplate code like get and set
  • hibernate-validator is required for implementation of the validation
  • jackson-databind is for the java object serialization and deserialization
  • maven-compiler-plugin is for compiling the code using source version 17 and target version 17
  • A war artifact will be created for deployment to servlet containers
Some notes about build.gradle:
  • spring-webmvc for necessary Spring REST controllers and its dependencies
  • lombok to remove boilerplate code like get and set
  • hibernate-validator is required for implementation of the validation
  • jackson-databind is for the java object serialization and deserialization
  • sourceCompatibility and targetCompatibility is for compiling the code using source version 17 and target version 17
  • A war artifact will be created for deployment to servlet containers
web.xml:
<web-app> <display-name>Spring Web Service with validation</display-name> <servlet> <servlet-name>SpringWebServiceValidation</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>/WEB-INF/myApplicationContext.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>SpringWebServiceValidation</servlet-name> <url-pattern>/*</url-pattern> </servlet-mapping> </web-app>
Some notes about web.xml:
  • org.springframework.web.servlet.DispatcherServlet is the servlet class for Spring
  • /WEB-INF/myApplicationContext.xml is the location of the Spring context configuration file
myApplicationContext.xml:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd"> <context:component-scan base-package="controller,exception"/> <mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"/> </mvc:message-converters> </mvc:annotation-driven> </beans>
Some notes about myApplicationContext.xml:
  • <context:component-scan base-package="controller,exception"/> is for Spring to look for classes in specific packages to create beans
  • <mvc:message-converters> is to specify library for json serialization and deserialization
MyController.java:
package controller; import model.Customer; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; @RestController @RequestMapping("/myapp") public class MyController { @PostMapping("/customer") public void postCustomer(@Valid @RequestBody Customer customer) { System.out.println("Customer name: " + customer.getFirstName()); } }
Some notes about MyController.java:
  • It is a normal REST controller for taking POST request with "Customer" as the body
  • Put the annotation @Valid in front of the parameter for validation
Customer.java:
package model; import lombok.Data; import validator.NoDuplication; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import java.util.List; @Data public class Customer { @NotNull(message = "Please provide a lastName") private String lastName; private String firstName; @Email(message = "email is not in correct format") private String email; @Positive(message = "age must be positive") private Integer age; @Max(value = 5, message = "Maximum number of reference is 5") private Integer reference; @NoDuplication(message = "Duplicated hobbies are found") private List<String> hobbies; }
Some notes about Customer.java:
  • This is the Customer class used in the request body of the postCustomer method in MyController
  • Except for @NoDuplication, which is a customized constraint, other annotation is built-in constraints. Please refer to here for all the available constraints and their meanings
  • @NoDuplication is a customized constraint on validation, see below for explanation
ErrorResponse.java:
package model; import lombok.Data; @Data public class ErrorResponse { private int errorCode; private String errorMessage; }
Some notes about ErrorResponse.java:
  • This is the ErrorResponse if there is constraint violation
NoDuplication.java:
package validator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.Retention; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.*; import static java.lang.annotation.RetentionPolicy.RUNTIME; @Target(FIELD) @Retention(RUNTIME) @Constraint(validatedBy = NoDuplicationValidator.class) public @interface NoDuplication { String message() default "Duplications are found"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
Some notes about NoDuplication.java:
  • Create the annotation NoDuplication with @interface
  • Specify the customized NoDuplicationValidator (see below) as the validator
  • It requires the message for the default failed validation message
  • groups and payload just use default value
NoDuplicationValidator.java:
package validator; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import java.util.HashSet; import java.util.List; import java.util.Set; public class NoDuplicationValidator implements ConstraintValidator<NoDuplication, List<String>> { @Override public void initialize(NoDuplication constraintAnnotation) { } @Override public boolean isValid(List<String> list, ConstraintValidatorContext constraintValidatorContext) { boolean valid = true; if (list != null) { Set<String> set = new HashSet<>(list); if (set.size() != list.size()) { valid = false; } } return valid; } }
Some notes about NoDuplicationValidator.java:
  • It implements the interface ConstraintValidator, with NoDuplication as the annotation type and List<String> as the field type
  • Implementing the isValid method with customized logic to determine whether the value is valid or not. If the value is valid then return true, else return false
MyExceptionHandler.java:
package exception; import model.ErrorResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class MyExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException methodArgumentNotValidException) { ErrorResponse errorResponse = new ErrorResponse(); errorResponse.setErrorCode(HttpStatus.BAD_REQUEST.value()); errorResponse.setErrorMessage(methodArgumentNotValidException.getBindingResult().getFieldError().getDefaultMessage()); ResponseEntity<ErrorResponse> responseEntity = new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); return responseEntity; } }
Some notes about MyExceptionHandler.java:
  • Failed validation will throw an uncaught exception all the way escalating to the servlet container, which will display as an unfriendly response back to the client. So an exception handler is used to capture specific exception and return with a nicely formatted response
  • Mark @RestControllerAdvice in a class to capture exception globally and in a method define the annotation @ExceptionHandler with MethodArgumentNotValidException.class (which is the exception that will be thrown for failed validation)
  • In the method define customized response structure which is ErrorResponse in this case and return an instance of ResponseEntity
  • The failed validation message is retrieved from the method argument methodArgumentNotValidException
Sample requests and responses(assume the war file is deployed to <CONTEXT_PATH>):
  • Send a valid record:
    curl --request POST '<CONTEXT_PATH>myapp/customer' --header 'Content-Type: application/json' --data-raw ' { "firstName":"Sam", "lastName":"Johnson", "age":30, "email":"hithere@maxjava.net", "reference":1, "hobbies":["fishing","bowling"] }'
    Response status: 200 OK
     
  • Send a record which "lastName" is missing:
    curl --request POST '<CONTEXT_PATH>/myapp/customer' --header 'Content-Type: application/json' --data-raw ' { "firstName":"Sam", "age":30, "email":"hithere@maxjava.net", "reference":1, "hobbies":["fishing","bowling"] }'
    Response status: 400 Bad Request
    body:
    { "errorCode": 400, "errorMessage": "Please provide a lastName" }
  • Send a record which "age" is negative:
    curl --request POST '<CONTEXT_PATH>/myapp/customer' --header 'Content-Type: application/json' --data-raw ' { "firstName":"Sam", "lastName":"Johnson", "age":-10, "email":"hithere@maxjava.net", "reference":1, "hobbies":["fishing","bowling"] }'
    Response status: 400 Bad Request
    body:
    { "errorCode": 400, "errorMessage": "age must be positive" }
  • Send a record which "email" is not in email format:
    curl --request POST '<CONTEXT_PATH>/myapp/customer' --header 'Content-Type: application/json' --data-raw ' { "firstName":"Sam", "lastName":"Johnson", "age":10, "email":"hithere.maxjava.net", "reference":1, "hobbies":["fishing","bowling"] }'
    Response status: 400 Bad Request
    body:
    { "errorCode": 400, "errorMessage": "email is not in correct format" }
  • Send a record which "hobbies" has duplicated values:
    curl --request POST '<CONTEXT_PATH>/myapp/customer' --header 'Content-Type: application/json' --data-raw ' { "firstName":"Sam", "lastName":"Johnson", "age":30, "email":"hithere@maxjava.net", "reference":1, "hobbies":["fishing","bowling","fishing"] }'
    Response status: 400 Bad Request
    body:
    { "errorCode": 400, "errorMessage": "Duplicated hobbies are found" }