Search code examples
javaspringrestspring-bootfasterxml

Spring Boot Validate JSON Mapped via ObjectMapper GET @RequestParam


What's the simplest approach to validating a complex JSON object being passed into a GET REST contoller in spring boot that I am mapping with com.fasterxml.jackson.databind.ObjectMapper?

Here is the controller:

@RestController
@RequestMapping("/products")
public class ProductsController {

@GetMapping
public ProductResponse getProducts(
        @RequestParam(value = "params") String requestItem
) throws IOException {
    final ProductRequest productRequest =
            new ObjectMapper()
                    .readValue(requestItem, ProductRequest.class);

    return productRetriever.getProductEarliestAvailabilities(productRequest);
}}

DTO request object I want to validate:

public class ProductRequest {
private String productId;

public String getProductId() {
    return productId;
}

public void setProductId(String productId) {
    this.productId = productId;
}}

I was thinking of using annotations on the request DTO however when I do so, they are not triggering any type of exceptions, i.e. @NotNull. I've tried various combinations of using @Validated at the controller as well as @Valid in the @RequestParam and nothing is causing the validations to trigger.


Solution

  • In my point of view, Hibernate Bean Validator is probably one of the most convenient methods to validate the annotated fields of a bean anytime and anywhere. It's like setup and forget

    • Setup the Hibernate Bean Validator
    • Configure how the validation should be done
    • Trigger the validator on a bean anywhere

    I followed the instructions in the documentation given here

    Setup dependencies

    I use Gradle so, I am going to add the required dependencies as shown below

    // Hibernate Bean validator
    compile('org.hibernate:hibernate-validator:5.2.4.Final')
    

    Create a generic bean valdiator

    I setup a bean validator interface as described in the documentation and then use this to validate everything that is annotated

    public interface CustomBeanValidator {
        /**
         * Validate all annotated fields of a DTO object and collect all the validation and then throw them all at once.  
         * 
         * @param object
         */
        public <T> void validateFields(T object); 
    }
    

    Implement the above interface as follow

    @Component
    public class CustomBeanValidatorImpl implements CustomBeanValidator {
        ValidatorFactory valdiatorFactory = null; 
    
        public CustomBeanValidatorImpl() {
            valdiatorFactory = Validation.buildDefaultValidatorFactory(); 
        }
    
        @Override
        public <T> void validateFields(T object) throws ValidationsFatalException {
            Validator validator = valdiatorFactory.getValidator(); 
            Set<ConstraintViolation<T>> failedValidations = validator.validate(object); 
    
            if (!failedValidations.isEmpty()) {
                List<String> allErrors = failedValidations.stream().map(failure -> failure.getMessage())
                        .collect(Collectors.toList());
                throw new ValidationsFatalException("Validation failure; Invalid request.", allErrors);
            }
        }
    }
    

    The Exception class

    The ValidationsFatalException I used above is a custom exception class that extends RuntimeException. As you can see I am passing a message and a list of violations in case the DTO has more than one validation error.

    public class ValidationsFatalException extends RuntimeException {
        private String message; 
        private Throwable cause;
        private List<String> details; 
    
        public ValidationsFatalException(String message, Throwable cause) {
            super(message, cause);
        } 
    
        public ValidationsFatalException(String message, Throwable cause, List<String> details) {
            super(message, cause); 
            this.details = details;
        }
    
        public List<String> getDetails() {
            return details; 
        }
    }
    

    Simulation of your scenario

    In order to test whether this is working or not, I literally used your code to test and here is what I did

    • Create an endpoint as shown above
    • Autowire the CustomBeanValidator and trigger it's validateFields method passing the productRequest into it as shown below
    • Create a ProductRequest class as shown above
    • I annotated the productId with @NotNull and @Length(min=5, max=10)
    • I used Postman to make a GET request with a params having a value that is url-encoded json body

    Assuming that the CustomBeanValidator is autowired in the controller, trigger the validation as follow after constructing the productRequest object.

    beanValidator.validateFields(productRequest);
    

    The above will throw exception if any violations based on annotations used.

    How is the exception handled by exception controller?

    As mentioned in the title, I use ExceptionController in order to handle the exceptions in my application.

    Here is how the skeleton of my exception handler where the ValidationsFatalException maps to and then I update the message and set my desired status code based on exception type and return a custom object (i.e. the json you see below)

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler({SomeOtherException.class, ValidationsFatalException.class})
    public @ResponseBody Object handleBadRequestExpection(HttpServletRequest req, Exception ex) {
        if(ex instanceof CustomBadRequestException) 
            return new CustomResponse(400, HttpStatus.BAD_REQUEST, ex.getMessage()); 
        else 
            return new DetailedCustomResponse(400, HttpStatus.BAD_REQUEST, ex.getMessage(),((ValidationsFatalException) ex).getDetails()); 
    }
    

    Test 1

    Raw params = {"productId":"abc123"}
    Url encoded parmas = %7B%22productId%22%3A%22abc123%22%7D
    Final URL: http://localhost:8080/app/product?params=%7B%22productId%22%3A%22abc123%22%7D
    Result: All good.

    Test 2

    Raw params = {"productId":"ab"}
    Url encoded parmas = %7B%22productId%22%3A%22ab%22%7D
    Final URL: http://localhost:8080/app/product?params=%7B%22productId%22%3A%22ab%22%7D
    Result:

    {
        "statusCode": 400,
        "status": "BAD_REQUEST",
        "message": "Validation failure; Invalid request.",
        "details": [
            "length must be between 5 and 10"
        ]
    }
    

    You can expand the Validator implementation to provide a mapping of field vs message error message.