Validation With Spring Boot
When building a Spring Boot application, you will need to validate the input of web requests, the input to your services, etc. In this blog, you will learn how to add validation to your Spring Boot application. Enjoy!
Introduction
In order to validate input, the Jakarta Bean Validation specification will be used. The Jakarta Bean Validation specification is a Java specification that allows you to validate input, models, etc by means of annotations. One of the implementations of the specification is Hibernate Validator. Using Hibernate Validator does not mean that you will also be using the Hibernate ORM (Object Relational Mapping). It is just another project under the Hibernate flag. With Spring Boot, you can add the spring-boot-starter-validation
dependency which uses the Hibernate Validator for validating.
In the remainder of this blog, you will create a basic Spring Boot application and add validation to the Controller and the Service.
Sources used in this blog can be found at GitHub.
Prerequisites
Prerequisites for this blog are:
- Basic Java knowledge, Java 21 is used;
- Basic Spring Boot knowledge;
- Basic knowledge of OpenAPI, if you do not have any experience with OpenAPI, it is suggested to read a previous blog.
Basic Application
The project you will build in this blog is a basic Spring Boot project. The domain is a Customer with an id
, a firstName
and a lastName
.
public class Customer {
private Long customerId;
private String firstName;
private String lastName;
...
}
By means of a Rest API, a customer can be created and retrieved. In order to keep the API specification and source code in line with each other, you will use the openapi-generator-maven-plugin
. First, you write the OpenAPI specification and the plugin will generate the source code for you based on the specification. The OpenAPI specification consists out of two endpoints, one for creating a customer (POST) and one for retrieving the customer (GET). The OpenAPI specification contains some constraints:
- The Customer schema used in the POST request limits the number of characters for
firstName
andlastName
. At least one character needs to be provided and a maximum of 20 characters is allowed. - The GET request requires the
customerId
as an input parameter.
openapi: "3.1.0"
info:
title: API Customer
version: "1.0"
servers:
- url: https://localhost:8080
tags:
- name: Customer
description: Customer specific data.
paths:
/customer:
post:
tags:
- Customer
summary: Create Customer
operationId: createCustomer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Customer'
responses:
'200':
description: OK
content:
'application/json':
schema:
$ref: '#/components/schemas/CustomerFullData'
/customer/{customerId}:
get:
tags:
- Customer
summary: Retrieve Customer
operationId: getCustomer
parameters:
- name: customerId
in: path
required: true
schema:
type: integer
format: int64
responses:
'200':
description: OK
content:
'application/json':
schema:
$ref: '#/components/schemas/CustomerFullData'
'404':
description: NOT FOUND
components:
schemas:
Customer:
type: object
properties:
firstName:
type: string
description: First name of the customer
minLength: 1
maxLength: 20
lastName:
type: string
description: Last name of the customer
minLength: 1
maxLength: 20
CustomerFullData:
allOf:
- $ref: '#/components/schemas/Customer'
- type: object
properties:
customerId:
type: integer
description: The ID of the customer
format: int64
description: Full data of the customer.
The generated code generates an interface that the CustomerController
implements.
createCustomer
maps the API model to the domain model and invokes theCustomerService
;getCustomer
invokes theCustomerService
and maps the domain model to the API model.
@RestController
class CustomerController implements CustomerApi {
private final CustomerService customerService;
CustomerController(CustomerService customerService) {
this.customerService = customerService;
}
@Override
public ResponseEntity<CustomerFullData> createCustomer(Customer apiCustomer) {
com.mydeveloperplanet.myvalidationplanet.domain.Customer customer = new com.mydeveloperplanet.myvalidationplanet.domain.Customer();
customer.setFirstName(apiCustomer.getFirstName());
customer.setLastName(apiCustomer.getLastName());
return ResponseEntity.ok(domainToApi(customerService.createCustomer(customer)));
}
@Override
public ResponseEntity<CustomerFullData> getCustomer(Long customerId) {
com.mydeveloperplanet.myvalidationplanet.domain.Customer customer = customerService.getCustomer(customerId);
return ResponseEntity.ok(domainToApi(customer));
}
private CustomerFullData domainToApi(com.mydeveloperplanet.myvalidationplanet.domain.Customer customer) {
CustomerFullData cfd = new CustomerFullData();
cfd.setCustomerId(customer.getCustomerId());
cfd.setFirstName(customer.getFirstName());
cfd.setLastName(customer.getLastName());
return cfd;
}
}
The CustomerService
puts the customer in a Map
, no database or whatsoever is used.
@Service
class CustomerService {
private final HashMap<Long, Customer> customers = new HashMap<>();
private Long index = 0L;
Customer createCustomer(Customer customer) {
customer.setCustomerId(index);
customers.put(index, customer);
index++;
return customer;
}
Customer getCustomer(Long customerId) {
if (customers.containsKey(customerId)) {
return customers.get(customerId);
} else {
return null;
}
}
}
Build the application and run the tests.
$ mvn clean verify
Controller Validation
The Controller validation is fairly easy now. Just add the spring-boot-starter-validation
dependency to your pom.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Take a closer look at the generated CustomerApi
interface which is located in target/generated-sources/openapi/src/main/java/com/mydeveloperplanet/myvalidationplanet/api/
.
- At the class level, the interface is annotated with
@Validated
. This will tell Spring to validate the parameters of the methods. - The
createCustomer
method signature contains the@Valid
annotation for theRequestBody
. This will tell Spring that this parameter needs to be validated. - The
getCustomer
method signature contains the required property for thecustomerId
.
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2024-03-30T09:31:30.793931181+01:00[Europe/Amsterdam]")
@Validated
@Tag(name = "Customer", description = "Customer specific data.")
public interface CustomerApi {
...
default ResponseEntity<CustomerFullData> createCustomer(
@Parameter(name = "Customer", description = "") @Valid @RequestBody(required = false) Customer customer
) {
...
}
...
default ResponseEntity<CustomerFullData> getCustomer(
@Parameter(name = "customerId", description = "", required = true, in = ParameterIn.PATH) @PathVariable("customerId") Long customerId
) {
...
}
...
}
The cool part is, that based on the OpenAPI specification, the correct annotations are put in place in the generated code. You do not need to do anything special in order to add validation to your Rest API.
Let’s test whether validation is doing its job. Only the Controller is tested, the Service is mocked and you will be using the @WebMvcTest
annotation in order to slice the test to its minimum.
- Test whether a
BadRequest
is returned when a customer is created using alastName
with too many characters; - Test a valid customer;
- Test whether a
BadRequest
is returned when a customer is retrieved by means of acustomerId
which is not an integer; - Test retrieving a valid customer.
@WebMvcTest(controllers = CustomerController.class)
class CustomerControllerTest {
@MockBean
private CustomerService customerService;
@Autowired
private MockMvc mvc;
@Test
void whenCreateCustomerIsInvalid_thenReturnBadRequest() throws Exception {
String body = """
{
"firstName": "John",
"lastName": "John who has a very long last name"
}
""";
mvc.perform(post("/customer")
.contentType("application/json")
.content(body))
.andExpect(status().isBadRequest());
}
@Test
void whenCreateCustomerIsValid_thenReturnOk() throws Exception {
String body = """
{
"firstName": "John",
"lastName": "Doe"
}
""";
Customer customer = new Customer();
customer.setCustomerId(1L);
customer.setFirstName("John");
customer.setLastName("Doe");
when(customerService.createCustomer(any())).thenReturn(customer);
mvc.perform(post("/customer")
.contentType("application/json")
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("firstName", equalTo("John")))
.andExpect(jsonPath("lastName", equalTo("Doe")))
.andExpect(jsonPath("customerId", equalTo(1)));
}
@Test
void whenGetCustomerIsInvalid_thenReturnBadRequest() throws Exception {
mvc.perform(get("/customer/abc"))
.andExpect(status().isBadRequest());
}
@Test
void whenGetCustomerIsValid_thenReturnOk() throws Exception {
Customer customer = new Customer();
customer.setCustomerId(1L);
customer.setFirstName("John");
customer.setLastName("Doe");
when(customerService.getCustomer(any())).thenReturn(customer);
mvc.perform(get("/customer/1"))
.andExpect(status().isOk());
}
}
Service Validation
Adding validation to the Service requires a bit more effort, but still, it is fairly easy.
Add the validation constraints to the model. A complete list of the constraints can be found in the Hibernate Validator documentation.
The following constraints are added:
firstName
should not be empty and must be between 1 and 20 characters;lastName
should not be empty and must be between 1 and 20 characters.
public class Customer {
private Long customerId;
@Size(min = 1, max = 20)
@NotEmpty
private String firstName;
@Size(min = 1, max = 20)
@NotEmpty
private String lastName;
...
}
In order to enable the validation in the Service, two approaches exist. One approach is to inject a Validator
in the Service and explicitly validate a customer. This Validator
is provided by Spring Boot. If violations are found, you can create an error message.
@Service
class CustomerService {
private final HashMap<Long, Customer> customers = new HashMap<>();
private Long index = 0L;
private final Validator validator;
CustomerService(Validator validator) {
this.validator = validator;
}
Customer createCustomer(Customer customer) {
Set<ConstraintViolation<Customer>> violations = validator.validate(customer);
if (!violations.isEmpty()) {
StringBuilder sb = new StringBuilder();
for (ConstraintViolation<Customer> constraintViolation : violations) {
sb.append(constraintViolation.getMessage());
}
throw new ConstraintViolationException("Error occurred: " + sb, violations);
}
customer.setCustomerId(index);
customers.put(index, customer);
index++;
return customer;
}
...
}
In order to test the validation of the Service, you need to add the @SpringBootTest
annotation. The disadvantage is that it will be a costly test because it will start a full Spring Boot application. Two tests are added:
- Test whether a
ConstraintViolationException
is thrown when a customer is created using alastName
with too many characters; - Test a valid customer.
@SpringBootTest
class CustomerServiceTest {
@Autowired
private CustomerService customerService;
@Test
void whenCreateCustomerIsInvalid_thenThrowsException() {
Customer customer = new Customer();
customer.setFirstName("John");
customer.setLastName("John who has a very long last name");
assertThrows(ConstraintViolationException.class, () -> {
customerService.createCustomer(customer);
});
}
@Test
void whenCreateCustomerIsValid_thenCustomerCreated() {
Customer customer = new Customer();
customer.setFirstName("John");
customer.setLastName("Doe");
Customer customerCreated = customerService.createCustomer(customer);
assertNotNull(customerCreated.getCustomerId());
}
}
The second approach to add validation is the same approach as used for the Controller. You add the @Validated
annotation to the class level and the @Valid
annotation to the argument you want to validate.
@Service
@Validated
class CustomerValidatedService {
private final HashMap<Long, Customer> customers = new HashMap<>();
private Long index = 0L;
Customer createCustomer(@Valid Customer customer) {
customer.setCustomerId(index);
customers.put(index, customer);
index++;
return customer;
}
...
}
Custom Validators
When the standard validators are not sufficient for your use case, you can create your own validators. Let’s create a custom validator for a Dutch zipcode. A Dutch zipcode consists of 4 digits followed by two characters.
First, you need to create your own constraint annotation. In this case, you only specify the constraint violation message you want to use.
@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = DutchZipcodeValidator.class)
@Documented
public @interface DutchZipcode {
String message() default "A Dutch zipcode must contain 4 digits followed by two letters";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
The annotation is validated by the class DutchZipcodeValidator
. This class implements a ConstraintValidator
. The isValid
method is used to implement the checks and to return whether the input is valid or not. In this case, the checks are implemented by means of a regular expression.
public class DutchZipcodeValidator implements ConstraintValidator<DutchZipcode, String> {
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
Pattern pattern = Pattern.compile("\\b\\d{4}\\s?[a-zA-Z]{2}\\b");
Matcher matcher = pattern.matcher(s);
return matcher.matches();
}
}
In order to use the new constraint, you add a new Address
domain entity containing a street
and a zipcode
. The zipcode
is annotated with @DutchZipcode
.
The new constraint can be tested by means of a basic unit test.
class ValidateDutchZipcodeTest {
@Test
void whenZipcodeIsValid_thenOk() {
Address address = new Address("street", "2845AA");
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<Address>> violations = validator.validate(address);
assertTrue(violations.isEmpty());
}
@Test
void whenZipcodeIsInvalid_thenNotOk() {
Address address = new Address("street", "2845");
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<Address>> violations = validator.validate(address);
assertFalse(violations.isEmpty());
}
}
Conclusion
Adding validation to a Controller is almost for free if you define your OpenAPI specification thoroughly and generate the code by means of the openapi-generator-maven-plugin
. With limited effort, you can add validation to your Service as well. The Hibernate Validator which is used by Spring Boot offers quite some constraints to be used and you can create your own custom constraints if you need to.