The power of Spring REST API validation

Łukasz Monkiewicz
6 min readApr 15, 2021

--

Creating REST endpoints has become pretty popular. All these microservices, APIs, simple backend applications for the frontend javascript apps often use REST for communication. But sometimes, requests that are made toward them may have errors. Bad format, missing required values, missing properties. How to handle it? Let’s see.

All the above errors are pretty simple most of the time, but nevertheless, they can cause trouble in the business logic and we have to protect our services from them. The best solution here is to fail fast, that is to validate the request as soon as possible, check everything we can when it comes to the request format, and if it is wrong, return an error before we hit our business logic with the request. We have to ensure that all requests that are coming into it are valid. The approach like this gives us two things. First, we do not bother our services with requests that cannot succeed anyway. The second thing is that we can remove a lot of sanity checks from business logic, we can assume that all the data will be correct, everything that is required will be there. This simplifies the code a lot.

Simple validation

Spring offers the simplest form of validation out of the box. When you define a REST endpoint and it’s PathVariable or RequestParameter we can specify if a particular parameter is required or not. Both are required by default but we can easily change it to optional:

@GetMapping("/hello/{name}")
private String hello(@PathVariable(value = "name", required = true) String name){
//...
}
@GetMapping("/name")
private ResponseEntity<?> queryPerson(@RequestParam(value = "query", required = false) String query) {
// ...
}

With these simple annotations, Spring will validate all requests, and respond with 400 Bad Request when the required parameter is missing or has a wrong type. For example, if we declare a parameter as an Integer, and we will pass some kind of String in the request, we will also receive an error response.

Request body validation

When it comes to validation a request body with some complex objects that represent a parsed JSON we have to do a bit more.

First, we have to annotate our object with all the constraints and requirements. Spring has built-in support for JSR 303 Bean Validation which makes it really easy. We just annotate our fields, with NotNull, Max, Min, Pattern annotations with desired parameters. If we have some nested objects there, we have to add Valid annotation on a field with this objects, so that it will be validated.

Here’s a simple example of adding a null check to the fields:

public class Message {
@NotNull
private String title;
@NotNull
private String message;
// getters/setters/etc
}

And on the controller endpoint we add the Valid annotation:

@PostMapping
public ResponseEntity<?> createMessage(@Valid @RequestBody Message message) {
// ...
}

Custom validators

JSR 303 Validation gives a pretty broad set of validators out of the box. We can check string lengths, number max and min values, use regular expressions etc. But sometimes, we have a need for a specific kind of validation. That’s when we have to write our own validator and annotation.

For validator, we have to implement ConstraintValidator interface, we define an initialize method that is used to read parameters from annotations and isValid method that performs the validation. As you can see, this validation checks if given Integer value is in specified range:

public class InRangeValidator implements ConstraintValidator<InRange, Integer> {    private int min;
private int max;
@Override
public void initialize(InRange constraintAnnotation) {
this.min = constraintAnnotation.min();
this.max = constraintAnnotation.max();
}
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
return value == null || (value >= min && value <= max);
}
}

Annotations are also really simple, we have to specify the validator class using Constraint annotation, and define available parameters, their default values, and default error message:

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = { InRangeValidator.class })
public @interface InRange {
String message() default "Value is out of range";
Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; int min() default Integer.MIN_VALUE; int max() default Integer.MAX_VALUE;
}

Now, we can just put InRange annotation on our field. And that’s it. Simple and readable.

Complex, conditional validation

There are sometimes cases, where a simple field by field validation is not enough. Sometimes we have to check field values depending on other field value. This is also possible to do.

Let’s say that we want to check an address. Let’s say that name cannot be empty and if it is a company address, company taxId has to be provided.

Annotation is as simple as before:

@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = { IsCorrectAddressValidator.class })
public @interface IsCorrectAddress {
String message() default "Address is incorrect";
Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};
}

But our validator is a bit more complex as it got more logic inside:

public class IsCorrectAddressValidator implements ConstraintValidator<IsCorrectAddress, Address> {
@Override
public void initialize(IsCorrectAddress constraintAnnotation) {
} @Override
public boolean isValid(Address value, ConstraintValidatorContext context) {
if (value == null) return true;
if (value.getName() == null || value.getName().isEmpty()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("Name is required")
.addPropertyNode("name").addConstraintViolation();
return false;
}
if (value.isCompany()){
if (value.getTaxId() == null || value.getTaxId().isEmpty()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("TaxId is required for company address")
.addPropertyNode("taxId").addConstraintViolation();
return false;
}
}
return true;
}
}

Most complexity above comes from the definition of custom error messages on each error. Using only one error message for complex multifield validation is not a good practice. To add a custom error message in the validator, first, we have to disable a default error message with disableDefaultConstraintViolation() method. Then we can add our messages and even describe on which field the error has occurred.

Different validation for different endpoint

Another case of validation is that we can use the same objects in different contexts. For example for creating and updating. For updating, we would require a nonnull id of the object. For creating id should be null. We can achieve this using groups parameter in JSR303 annotations. The idea is, that we define few groups, as interfaces without methods. Then, for each annotation, we set group or groups for which this annotation should be active.

Here’s a simple example:

public class User {    @NotNull(groups = Existing.class)
@Null(groups = New.class)
private Long id;
@NotNull(
message = "First name is required",
groups = {Existing.class, New.class}
)
private String firstName;
@NotNull(groups = {Existing.class, New.class})
private String lastName;
@NotNull(groups = {Existing.class, New.class})
@InRange(
min=18,
message = "User must be at least 18 years old",
groups = {Existing.class, New.class}
)
private Integer age;
public interface Existing {
}
public interface New {
}
}

Now, we have to tell spring, which group to use. We do this with Validated annotation, instead of the Valid annotation:

@PostMapping
public ResponseEntity<?> createUser(@Validated(User.New.class) @RequestBody User user) {
// ...
}

Custom error response

Ok, we have can now detect all kind of errors. But it would be good to tell the other guy what’s wrong and simple status code and one line message may not be enough or we may want to return it as JSON in our own format. For this, we can add an error handling to our controllers.

@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleException(MethodArgumentNotValidException exception) {
String errorMsg = exception.getBindingResult().getFieldErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.findFirst()
.orElse(exception.getMessage());
return ErrorResponse.builder().message(errorMsg).build();
}

Above you can see, that we are “catching” here an exception thrown by the JSR303 validation, extract all the field errors, grab the first one and build our own response object that will be later serialized to JSON and returned in response.

Summary

Adding validation to Spring controllers is a breeze. You don’t have to create your own framework that does this, everything is built-in and ready to use and what’s important, is that it is also very easy to extend and customize it to the needs of a project.

--

--

Łukasz Monkiewicz
Łukasz Monkiewicz

Written by Łukasz Monkiewicz

Architect, Developer, Software Engineer

No responses yet