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:
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 , throworg.springframework.web.bind.MethodArgumentNotValidException
. Otherwise , continues invoking the controller method with theBindingResult
arguments capturing with the validation error information.
- enable when the controller method 's argument is annotated with
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
.
- enabled for a spring bean if it is annotated with
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 hasBindingResult
argument , it skip throwingMethodArgumentNotValidException
and continue invoking the controller methodMethodValidationInterceptor
validates the request is incorrect , and throwConstraintViolationException
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:
@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
?