Presence of BindingResult method parameter determines exception thrown?
Asked Answered
G

3

8

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.

Gayton answered 21/10, 2021 at 1:58 Comment(0)
K
14

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 .

To disable it globally for all controllers , you can create a @ControllerAdvice with the following @InitBinder method:

@ControllerAdvice
public class InitBinderControllerAdvice {

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

To disable it just for a one controller, you can add this @InitBinder method to that controller :

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

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

}

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

Killy answered 23/10, 2021 at 21:2 Comment(2)
Fascinating. That makes ... some sense now, even if the docs are (for once) inadequate. Can I put the @InitBinder in the controller directly if I want to only disable the data binder in that one controller, or must I use a controller advice and apply it globally?Gayton
yes. you can put the @InitBinder to a controller which will only take effect for that controller if you do not want it to apply globally.Killy
B
1

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?

Bachman answered 23/10, 2021 at 12:49 Comment(0)
L
0

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...

Lem answered 23/10, 2021 at 7:7 Comment(2)
Except that's not what happens. Perhaps the BindingResuly is populated, but since the constraint violation exception is thrown we never get to find out. The method will never be invoked on validation failure and instead it will be deferred to the exception handler. There's no more code to see; the constraint exception is due to the failed javax.validation constraints and is thrown out of some part of the framework that's triggered by @Valid.Gayton
Can you help to add full stack trace of exception when you got ConstraintViolationException... According to article baeldung.com/spring-boot-bean-validation, 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 ...Lem

© 2022 - 2024 — McMap. All rights reserved.