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
OpenJDK 17.
[ 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.
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.
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.
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.
There are two layers of the validation involves at here which happen in the following orders:
Controller layer :
@RequestBody
or @ModelAttribute
and with @Valid
or @Validated
or any annotations whose name start with "Valid" (refer this for the logic).DataBinder
stuffBindingResult
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.Bean 's method layer :
@Validated
and the method argument or the returned value is annotated only with the bean validation annotations such as @Valid
, @Size
etc.MethodValidationInterceptor
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:
DataBinder
validates the request is incorrect but since the controller method has BindingResult
argument , it skip throwing MethodArgumentNotValidException
and continue invoking the controller method
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
.
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
?
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With