‘At least one’ validator

Posted by

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:

body of CreateProduct request

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:

  1. FirstOption – only productId must be not null
  2. SecondOption – only productCode must be not null
  3. 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

Links to read

  1. Spring validation
  2. Spring MVC Custom Validation
  3. JavaX validation groups
  4. Blog post about Spring Validation in Russian

Leave a Reply

Your email address will not be published. Required fields are marked *