In a microservice architecture, services may accept several, if not many, of the same inputs. This pattern can easily lead to code duplication and redundancy between services. In an effort to mitigate these drawbacks and keep service code focused, we can devise a robust solution involving several APIs provided by Spring and Java.
The following tutorial will assume some working knowledge of Java and Spring Boot, but will cater to a range of skill levels. Regardless, it never hurts to see another developer's code!
Background
Our solution will involve combining the Java and Spring Boot APIs, ConstraintValidator
and ResponseEntityExceptionHandler
, respectively.
Hibernate Validator, enhanced as part of JSR 380, is a specification of the Java API for standard Bean validation. In the context of Spring Boot applications, you may have used this without thinking twice. Examples include:
@NotNull
@Min
@Max
@Pattern
@Past
@Email
@PositiveOrZero
In this tutorial, we will examine how to go beyond these basic validations using the ConstraintValidator
interface to define our own set of constraints.
While other forms of exception handling exist within the Spring ecosystem, the ResponseEntityExceptionHandler
provides global (and centralized) exception handling within a service. This globalization is key to the efficacy of our custom constraint annotation since it allows us to validate multiple Beans (or fields within them). That said, we will investigate how we can leverage this class to gracefully handle violations to our constraints.
Implementation
Let's dive in. To avoid bloating the tutorial with boilerplate code, you will only find necessary blocks of code in the sections below. This article is coupled with a working example on GitHub.
Note: I will be making references to this example project throughout the tutorial.
Dependencies
The list is short and sweet:
implementation 'org.springframework.boot:spring-boot-starter-validation'
Creating the Annotations
We'll start simple. Let's say we've implemented a Fridge and Pantry Service of which allows us to:
- Manage the Fridge and Pantry repositories
- Accept POST and/or PUT requests with a JSON payload
We want to validate common fields between request models of both services. Our request model may look something like this:
public class FoodRequestModel {
private String name;
@PositiveOrZero(message = "Quantity must be positive")
@Max(value = 25, message = "Quantity must not exceed 25")
private int quantity;
private String category
private boolean refrigerated;
}
One of the simplest constraints we can build will involve composing existing constraints, such as @PositiveOrZero
and @Max
in the example above. This allows us to put an explicit label on common constraints and call it "business logic". Below, we define @FoodQuantity
:
@Documented
@PositiveOrZero(message = "Quantity must be positive")
@Max(value = 25, message = "Quantity must not exceed 25")
@Constraint(validatedBy = {})
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
public @interface FoodQuantity {
String message() default "Invalid quantity";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
There's a lot going on here, so let's break this down:
-
@Constraint
marks an annotation as being a Bean Validation constraint and allows us to specifyConstraintValidator
implementations; zero, one, or many implementations are welcome here -
@Retention
is set such that our annotation will be retained at runtime -
@Target
is set such that we can validate different types of inputs to our services -
message
,groups
, andpayload
are required by@Constraint
but do not have to be set--these provide specificity beyond what we'll cover today
By no means is this a simplification. Looking past the verbosity, the annotation opens quite a few doors to make handling complexity a breeze as we'll see in the next example.
Let's define a constraint for the category field such that:
- Category must be passed and cannot be empty
- Only certain categories are allowed to be passed
- Categories may differ between the Fridge and Pantry services
To implement this annotation, we will expand upon the premise of our first annotation by adding a custom parameter and providing an implementation of the ConstraintValidator
interface. The result looks something like this:
@Documented
@NotNull(message = "Category must be present")
@NotEmpty(message = "Category must not be empty")
@Constraint(validatedBy = FoodCategoryValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
public @interface FoodCategory {
String message() default "Invalid category";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String[] allowed() default {"dairy", "grain"};
}
A few more things are happening in this annotation compared to @FoodQuantity
. We've specified a new parameter, allowed
, to restrict what may be passed into category
. Notice the default value--this array is only referenced if values are not passed into @FoodCategory
. To handle this constraint, we've implemented FoodCategoryValidator
:
@Slf4j
public class FoodCategoryValidator
implements ConstraintValidator<FoodCategory, String> {
List<String> allowed;
@Override
public void initialize(FoodCategory constraintAnnotation) {
this.allowed = Arrays.asList(constraintAnnotation.allowed());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
log.info("isValid: value=[{}]", value);
if (!allowed.contains(value.toLowerCase())) {
String err = "Category must be one of the following: " + allowed;
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(err)
.addConstraintViolation();
return false;
}
return true;
}
}
Let's breakdown our new validator class:
-
ConstraintValidator
is parameterized with the annotation class and the type being validated--aString
containing the value of category - A global field
allowed
, set within the overriddeninitialize
method- It is within this method that we gain access to the parameters of
@FoodCategory
for use throughout the validator class
- It is within this method that we gain access to the parameters of
-
isValid
is the meat and bones of our constraint validation- For invalid scenarios, we disable the default constraint violation, build a proper error message, and return false--this eventually throws an exception we'll be interested in later
Lastly, to get the most out of our annotation, we'll propagate the category field into two subclasses pertaining to each service.
After all our hard work, we've reached a clean set of request models ready to be bombarded with invalid values:
public class FoodRequestModel {
private String name;
@FoodQuantity
private int quantity;
private boolean refrigerated;
}
public class FridgeRequestModel extends FoodRequestModel {
@FoodCategory(allowed = {"dairy", "vegetables", "beer"})
private String category;
}
public class PantryRequestModel extends FoodRequestModel {
@FoodCategory(allowed = {"grains", "canned", "snacks"})
private String category;
}
Handling Validation Errors
Until now, we've only defined constraints we (and our consumers) must follow. Let's open up an endpoint to allow food to be added to the fridge and test our constraints:
@Slf4j
@Validated
@RestController
@RequestMapping(path = "/api/v1/fridge")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class FridgeController {
private final FridgeService fridgeService;
@PostMapping("/food")
public ResponseEntity<FoodResponseModel> addFoodToFridgeV1(
@Valid @RequestBody FridgeRequestModel request) {
log.info("addFoodToFridgeV1: request=[{}]", request);
FoodResponseModel response = fridgeService.addFoodToFridge(request);
return ResponseEntity.ok(response);
}
}
There are a few critical aspects to note for our annotations to work properly:
-
@Validated
must be used either at the class or method level to indicate where validation needs to take place -
@Valid
is used to mark a property for validation cascading, which triggers our constraints
Let's send a payload:
{
"name": "milk",
"category": "dairy",
"quantity": 2,
"refrigerated": true
}
Success! But let's see what happens when we send another payload we know will result in error:
{
"name": "pinto beans",
"category": "legumes",
"quantity": -3,
"refrigerated": true
}
Note we are violating multiple constraints for this request model. You should see a verbose response containing the details of the errors encountered and the exceptions thrown. This verbosity isn't ideal for us (or our consumers) to deal with, so let's filter out important details with a simple override of the ResponseEntityExceptionHandler
.
A further look into the error response provided by Spring, you may notice the exception thrown: MethodArgumentNotValidException
. This is the exception we will be interested in for handling constraint violations within the exception handler.
First, we need a model to capture relevant information. We're able to distill the following from the original Spring response:
public class ErrorResponseModel {
private final String errorMessage;
private final LocalDateTime timestamp;
private final String path;
}
Next, we'll construct both the global exception handler and a method to handle our constraints:
@Slf4j
@RestControllerAdvice
public class ErrorController extends ResponseEntityExceptionHandler {
@Override
protected final ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
List<String> errorMessages = ex.getBindingResult().getAllErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList());
log.error("handleMethodArgumentNotValid: errors=[{}]", errorMessages);
return new ResponseEntity<>(
ErrorResponseModel.builder()
.errorMessage(errorMessages.get(0))
.timestamp(LocalDateTime.now())
.path(request.getDescription(false))
.build(),
HttpStatus.BAD_REQUEST
);
}
}
Let's note a few things here:
-
@RestControllerAdvice
is just that--a specialized component for classes that declare@ExceptionHandler
methods shared across multiple controller classes - We override the
ResponseEntityExceptionHandler
'shandleMethodArgumentNotValid
method so we may- Log important information
- Build a small, focused error response
- Return an HTTP status code of our choice, based on the constraint violated
Upon sending a payload that violates the constraints we've defined, you should see a succinct response indicating where we went wrong:
{
"errorMessage": "Quantity must be positive",
"timestamp": "2020-11-20T19:18:31.1899697",
"path": "uri=/api/v1/pantry/food"
}
Challenge
This tutorial provides some basic forms of constraint validation within a Spring-/Java-based REST service. If you are looking to take things a bit further, here are a few places you can start:
- Explore what might differ for violating constraints of
@PathVariable
or@RequestParam
- Implement nested constraints within a complex request model
- Increase the flexibility of the HTTP status code returned to the consumer
- Expand the sample project to handle the nuance of a composite service--a Picnic Service, for instance
- Explore the
@Constraint
API further--what mightpayload
andgroups
be used for?
Closing
This concludes the tutorial on implementing custom constraint validators using annotations! Don't be afraid to let me know if I missed anything. I certainly welcome (and appreciate) criticism, questions, and the like.
For further reference, here is the GitHub repository with the full working code and examples presented in this article:
Verley93 / annotation-validation
β‘ Spring Boot request validation tutorial
Request Validation in Spring Boot Microservices
Description
This repository complements a tutorial written on Medium by Devlin Verley II. Check it out here!
Table of Contents
Installation
- Run
git clone https://github.com/Verley93/annotation-validation.git
in a command line tool - Open the project in IntelliJ or an editor of choice and allow the project to download the Gradle distribution and index properly
- Run the project, or create and run the jar within the terminal
Usage
Once the service is running locally (http://localhost:8080 by default), you will be free to make requests via Postman or your favorite platform for calling APIs.
Top comments (0)