Home > Back-end >  Presence of BindingResult method parameter determines exception thrown?
Presence of BindingResult method parameter determines exception thrown?

Time:10-24

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.

CodePudding user response:

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
    • 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 :

    • enabled for a spring bean if it is annotated with @Validated and the method argument is annotated only with the bean validation annotations such as @Valid , @Size etc.
    • Based on the AOP stuff. The method interceptor is MethodValidationInterceptor
    • 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 documentations 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.

CodePudding user response:

From spec:

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/MethodArgumentNotValidException.html

@Valid @RequestBody Request request

If your object is not valid, we always got an MethodArgumentNotValidException. The difference here is depended on BindingResult ...

Without BindingResult, the MethodArgumentNotValidException is thrown as expected.

With BindingResult, the error will be inserted to BindingResult. We often have to check if bindresult have an error or not and do something with it.

if (bindingResult.hasErrors()) {  
    // handle error or create bad request status
}

BindingResult: "General interface that represents binding results. Extends the interface for error registration capabilities, allowing for a Validator to be applied, and adds binding-specific analysis and model building. "

You can double check again the errors in binding result. I don't see an full code, so i don't know which is the cause of ConstraintViolationException, but i guess you skip the error in binding result and continue insert entity to database and violate few constrain...

CodePudding user response:

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?

  • Related