Are you familiar with Java Validation API? And Spring validation? (if not, take a look at the end of this article for links to read)
In my practice I had an interesting case. The task was to implement an web endpoint accepting the request which body consisted of three fields. And only one of them must be specified at the time.
Picture the request is “create a product by one of ID, CODE or ID_HASH”. Here is the body:

Basically, I could check all necessary fields being specified with only three if-else strings of code like:
if (requestDto.getProductId() != null) {
//
} else if (requestDto.getProductCode() != null) {
//
} else if (requestDto.getProductIdHash() != null) {
//
} else {
throw new IllegalArgumentException();
}
Code language: Java (java)
But it is not the proper way to write a code. Do you remember Clean Code and Clean Architecture by R.Martin?
Moreover, SpringBoot does already have a validation component in order to help us to check validity of objects.
So, I have written the Validator to validate the case described above.
Add dependency
build.gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.hibernate:hibernate-validator:6.1.7.Final'
}
Code language: JavaScript (javascript)
Implement a controller with validation enabled
Just add @Valid
before request body to enable validation of incoming request body right after unmarshalling from JSON string into POJO.
@RestController
public class ProductController {
@ResponseStatus(HttpStatus.CREATED)
@PostMapping(path = "/api/products")
public ApiResponseDto<CreateProductResponseDto> createProduct(@Valid @RequestBody CreateProductRequestDto requestDto) {
return ApiResponseDto.<CreateProductResponseDto>builder()
.status("OK")
.response(CreateProductResponseDto.builder()
.message(String.format("Product created with '%s'", detectIncomeParam(requestDto)))
.build())
.build();
}
private String detectIncomeParam(CreateProductRequestDto requestDto) {
if (requestDto.getProductId() != null) {
return requestDto.getProductId().toString();
} else if (requestDto.getProductCode() != null) {
return requestDto.getProductCode();
} else {
return requestDto.getProductIdHash();
}
}
}
Code language: Java (java)
Validation annotation
The annotation must be specified on class level – so the Target
is TYPE
.
And the valuable property is array of classes. The idea was taken from standard JavaX validation groups (see links in the end of the article).
@Documented
@Constraint(validatedBy = AtLeastOneGroupValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AtLeastOneGroupValidated {
String message() default "All constrain groups are not valid!";
Class<?>[] groups() default {};
/**
* These groups are for checking validation.
* If any of specified group passes the validation, then the whole validation passes.
*
* @return Validation groups (must be specified)
*/
Class<?>[] checkingGroups();
Class<? extends Payload>[] payload() default {};
}
Code language: Java (java)
Data Transport Object
Request DTO must be annotated with new annotation now:
@AtLeastOneGroupValidated(
checkingGroups = {
CreateProductRequestDto.FirstOption.class,
CreateProductRequestDto.SecondOption.class,
CreateProductRequestDto.ThirdOption.class
}, message = "One of 'productId', 'productCode' or 'productIdHash' must be specified!"
)
@Value
@Builder
@Jacksonized
public class CreateProductRequestDto {
@NotNull(groups = {
FirstOption.class
})
@Null(groups = {
SecondOption.class,
ThirdOption.class
})
Long productId;
@NotNull(groups = {
SecondOption.class
})
@Null(groups = {
FirstOption.class,
ThirdOption.class
})
String productCode;
@NotNull(groups = {
ThirdOption.class
})
@Null(groups = {
FirstOption.class,
SecondOption.class
})
String productIdHash;
public interface FirstOption {
}
public interface SecondOption {
}
public interface ThirdOption {
}
}
Code language: Java (java)
Please be attentive – I specified three cases with three interfaces:
- FirstOption – only
productId
must be not null - SecondOption – only
productCode
must be not null - ThirdOption – only
productIdHash
must be not null
The Validator
And the very validator is here:
public class AtLeastOneGroupValidator implements ConstraintValidator<AtLeastOneGroupValidated, Object> {
private Class<?>[] groups;
@Override
public void initialize(AtLeastOneGroupValidated constraintAnnotation) {
groups = constraintAnnotation.checkingGroups();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
final Validator validator;
try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
validator = factory.getValidator();
}
for (Class<?> group : groups) {
final Set<ConstraintViolation<Object>> violation = validator.validate(value, group);
if (violation.isEmpty()) {
return true;
}
}
return false;
}
}
Code language: Java (java)
It delegates the validation to provided validator creating it with the call to default validator factory. And it only checks whether at least one group passes the validation. In this case the whole object evaluated as passed the validation.
Exception handler
This approach cannot be completed without exception handler in order to transform the exception throwing by SpringBoot into user friendly response.
@Slf4j
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handle(MethodArgumentNotValidException e) {
log.error("Validation exception", e);
final Map<String, String> errors = new HashMap<>();
e.getBindingResult().getAllErrors().forEach((error) -> {
if (error instanceof FieldError) {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
} else {
errors.put(error.getObjectName(), error.getDefaultMessage());
}
});
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponseDto.builder()
.response(errors)
.status("VALIDATION_ERROR: Validation error")
.build());
}
}
Code language: Java (java)
Test case
Every code must be test covered.
@Test
void testValidation() throws Exception {
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(aCreateCardRequest())))
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value("VALIDATION_ERROR: Validation error"))
.andExpect(jsonPath("$.response").isMap())
.andExpect(jsonPath("$.response.createProductRequestDto").exists());
}
CreateProductRequestDto aCreateCardRequest() {
return CreateProductRequestDto.builder().build();
}
Code language: Java (java)
Here is the request/response communication:
MockHttpServletRequest:
HTTP Method = POST
Request URI = /api/products
Parameters = {}
Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"58"]
Body = {"productId":null,"productCode":null,"productIdHash":null}
Session Attrs = {}
Handler:
Type = me.bvn13.test.spring.validation.atleastonegroup.web.product.ProductController
Method = me.bvn13.test.spring.validation.atleastonegroup.web.product.ProductController#createProduct(CreateProductRequestDto)
Async:
Async started = false
Async result = null
Resolved Exception:
Type = org.springframework.web.bind.MethodArgumentNotValidException
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 400
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"response":{"createProductRequestDto":"One of 'productId', 'productCode' or 'productIdHash' must be specified!"},"status":"VALIDATION_ERROR: Validation error"}
Forwarded URL = null
Redirected URL = null
Cookies = []
Code language: PHP (php)
Source Code
Code of project at private Git