Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Presence of BindingResult method parameter determines exception thrown?

I have a Spring @RestController that has a POST endpoint defined like this:

@RestController
@Validated
@RequestMapping("/example")
public class Controller {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ResponseEntity<?> create(@Valid @RequestBody Request request,
                                    BindingResult _unused, // DO NOT DELETE
                                    UriComponentsBuilder uriBuilder) {
        // ...
    }
}

It also has an exception handler for javax.validation.ConstraintViolationException:

@ExceptionHandler({ConstraintViolationException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
ProblemDetails handleValidationError(ConstraintViolationException e) {...}

Our Spring-Boot app is using spring-boot-starter-validation for validation. The Request object uses javax.validation.* annotations to apply constraints to the various fields like this:

public class Request {

    private Long id;

    @Size(max = 64, message = "name length cannot exceed 64 characters")
    private String name;

    // ...
}

As presented above, if you POST a request with an invalid Request, the validation will throw a ConstraintViolationException, which will be handled by the exception handler. This works, we have unit tests for it, all is good.

I noticed that the BindingResult in the post method wasn't used (the name _unused and comment //DO NOT DELETE were sort of red flags.) I went ahead and deleted the parameter. All of a sudden, my tests broke -- the inbound request was still validated, but it would no longer throw a ConstraintValidationException ... now it throws a MethodArgumentNotValidException! Unfortunately I can't used this other exception because it doesn't contain the failed validation in the format that I need (and doesn't contain all the data I need either).

Why does the BindingResult presence in the argument list control which exception is thrown? How can I removed the unused variable and still throw the ConstraintViolationException when the javax.validation determines that the request body is invalid?


Spring-Boot 2.5.5

  • spring-boot-starter-web
  • spring-boot-starter-validation

OpenJDK 17.

like image 966
Roddy of the Frozen Peas Avatar asked Oct 21 '21 01:10

Roddy of the Frozen Peas


People also ask

What does BindingResult do in Spring?

[ BindingResult ] is Spring's object that holds the result of the validation and binding and contains errors that may have occurred. The BindingResult must come right after the model object that is validated or else Spring will fail to validate the object and throw an exception.

What is the use of @valid annotation?

The @Valid annotation ensures the validation of the whole object. Importantly, it performs the validation of the whole object graph. However, this creates issues for scenarios needing only partial validation. On the other hand, we can use @Validated for group validation, including the above partial validation.

What is the use of @valid Annotation in Spring boot?

When Spring Boot finds an argument annotated with @Valid, it automatically bootstraps the default JSR 380 implementation — Hibernate Validator — and validates the argument. When the target argument fails to pass the validation, Spring Boot throws a MethodArgumentNotValidException exception.

What is the use of @valid Annotation in Spring MVC?

The @Valid annotation will tell spring to go and validate the data passed into the controller by checking to see that the integer numberBetweenOneAndTen is between 1 and 10 inclusive because of those min and max annotations.


Video Answer


2 Answers

There are two layers of the validation involves at here which happen in the following orders:

  1. Controller layer :

    • enable when the controller method 's argument is annotated with @RequestBody or @ModelAttribute and with @Valid or @Validated or any annotations whose name start with "Valid" (refer this for the logic).
    • Based on the DataBinder stuff
    • Can only validate the request
    • In case of validation errors and there is no BindingResult argument in the controller method , throw org.springframework.web.bind.MethodArgumentNotValidException. Otherwise , continues invoking the controller method with the BindingResult arguments capturing with the validation error information.
  2. Bean 's method layer :

    • enable for a spring bean if it is annotated with @Validated and the method argument or the returned value is annotated only with the bean validation annotations such as @Valid , @Size etc.
    • Based on the AOP stuff. The method interceptor is MethodValidationInterceptor
    • Can validate both the request and response
    • In case of validation errors ,throw javax.validation.ConstraintViolationException.

Validation in both layers at the end will delegate to the bean validation to perform the actual validation.

Because the controller is actually a spring bean , validation in both layers can take effects when invoking a controller method which is exactly demonstrated by your case with the following things happens:

  1. DataBinder validates the request is incorrect but since the controller method has BindingResult argument , it skip throwing MethodArgumentNotValidException and continue invoking the controller method

  2. MethodValidationInterceptor validates the request is incorrect , and throw ConstraintViolationException

The documents does not mention such behaviour clearly. I make the above summary after reading the source codes. I agree it is confusing especially in your case when validations are enable in both layers and also with the BindingResult argument. You can see that the bean validation actually validate the request for two times which sounds awkward...

So to solve your problem , you can disable the validation in controller layer 's DataBinder and always relies on the bean method level validation . You can do it by creating @ControllerAdvice with the following @InitBinder method:

@ControllerAdvice
public class InitBinderControllerAdvice {

    @InitBinder
    private void initBinder(WebDataBinder binder) {
        binder.setValidator(null);
    }
} 
    

Then even removing BindingResult from the controller method , it should also throw out ConstraintViolationException.

like image 195
Ken Chan Avatar answered Oct 26 '22 15:10

Ken Chan


I didn't know that the presence of BindingResult in controller method can modify the type of exception thrown, as I have never added it as an argument to a controller method before. What I have typically seen is the MethodArgumentNotValidException thrown for request body validation failures and ConstraintViolationException thrown for request parameter, path variable and header value violations. The format of the error details within MethodArgumentNotValidException might be different than what is in ConstraintViolationException, but it usually contains all the information you need about the error. Below is an exception handler class I wrote for your controller:

package com.example.demo;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ControllerExceptionHandler {
    public static final Logger LOGGER = LoggerFactory.getLogger(ControllerExceptionHandler.class);

    @ExceptionHandler({ ConstraintViolationException.class })
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleValidationError(ConstraintViolationException exception) {
        LOGGER.warn("ConstraintViolationException thrown", exception);
        Map<String, Object> response = new HashMap<>();
        List<Map<String, String>> errors = new ArrayList<>();

        for (ConstraintViolation<?> violation : exception.getConstraintViolations()) {
            Map<String, String> transformedError = new HashMap<>();
            
            String fieldName = violation.getPropertyPath().toString();
            transformedError.put("field", fieldName.substring(fieldName.lastIndexOf('.') + 1));
            transformedError.put("error", violation.getMessage());

            errors.add(transformedError);
        }
        response.put("errors", errors);

        return response;
    }

    @ExceptionHandler({ MethodArgumentNotValidException.class })
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleValidationError(MethodArgumentNotValidException exception) {
        LOGGER.warn("MethodArgumentNotValidException thrown", exception);
        Map<String, Object> response = new HashMap<>();

        if (exception.hasFieldErrors()) {
            List<Map<String, String>> errors = new ArrayList<>();

            for (FieldError error : exception.getFieldErrors()) {
                Map<String, String> transformedError = new HashMap<>();
                transformedError.put("field", error.getField());
                transformedError.put("error", error.getDefaultMessage());

                errors.add(transformedError);
            }
            response.put("errors", errors);
        }

        return response;
    }
}

It transforms both the MethodArgumentNotValidException and ConstraintViolationException into the same error response JSON below:

{
    "errors": [
        {
            "field": "name",
            "error": "name length cannot exceed 64 characters"
        }
    ]
}

What information were you missing in a MethodArgumentNotValidException compared to a ConstraintViolationException?

like image 35
devatherock Avatar answered Oct 26 '22 17:10

devatherock