1

I am currently struggling with seemingly inconsistent behavior when dealing with request body validation in Spring REST Controllers.

Validation of singular entities result in a MethodArgumentNotValidException. Validation of Collections of the same entity class result in a ConstraintViolationException. My understanding is that the MethodArgumentNotValidException is the expected result during handling of Spring REST controllers, while ConstraintViolationExceptions are expected when Bean validation is done by Hibernate or other frameworks. However, in this case both occur while validating @RequestBody annotated entities.

Is there an explanation for this inconsistency? Furthermore, can I unify application behavior in any way? Handling of two different exception types translating validation exceptions completely differently is not ideal.

Example:

Controller:

@Validated
@RestController
@Slf4j
public class TestController {

  @PostMapping(path = "/test-entities")
  public ResponseEntity<Void> saveTestEntity(@Valid @RequestBody List<TestEntity> testEntities) {
    log.info("Received: {}", testEntities);
    return ResponseEntity.noContent()
        .build();
  }

  @PostMapping(path = "/test-entity")
  public ResponseEntity<Void> saveTestEntity(@Valid @RequestBody TestEntity testEntity) {
    log.info("Received {}", testEntity);
    return ResponseEntity.noContent()
        .build();
  }

}

Test Entity:

@Data
public class TestEntity {
  @NotEmpty String foo;
}

Controller Advice:

@ControllerAdvice
public class TestControllerAdvice {

  @ExceptionHandler(Exception.class)
  ResponseEntity<String> handleException(Exception exception) {
    return ResponseEntity.badRequest()
        .body(exception.getClass().getSimpleName());
  }

}

Test of Described Behavior

@WebMvcTest(controllers = TestController.class)
class ValidationExceptionsIssueApplicationTests {

  @Autowired
  MockMvc mockMvc;

  String expectedException = MethodArgumentNotValidException.class.getSimpleName();

  @Test
  void assertThatNoExceptionIsReturned() throws Exception {
    mockMvc.perform(post("/test-entities")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                [
                  {"foo": "bar"}
                ]
                """))
        .andExpect(status().isNoContent());
  }

  @Test
  void whenSingleEntityIsSent_thenMethodArgumentNotValidExceptionIsThrown() throws Exception {
    mockMvc.perform(post("/test-entity")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                  {"foo": ""}
                """))
        .andExpectAll(
            status().isBadRequest(),
            content().string(expectedException)
        );
  }

  @Test
  void whenArrayOfEntitiesAreSent_thenMethodArgumentNotValidExceptionIsThrown() throws Exception {
    mockMvc.perform(post("/test-entities")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                [
                  {"foo": ""}
                ]
                """))
        .andExpectAll(
            status().isBadRequest(),
            content().string(expectedException) // <-- fails
        );
  }

}
Ken Chan
  • 84,777
  • 26
  • 143
  • 172
void
  • 144
  • 8
  • there is a question here about the differences between the 2 exceptions: https://stackoverflow.com/questions/57010688/what-is-the-difference-between-constraintviolationexception-and-methodargumentno Additionally, this article also explains it pretty well: https://reflectoring.io/bean-validation-with-spring-boot/ – Emanuel Trandafir Mar 02 '23 at 13:43
  • Thank you. I am aware of the difference you mentioned. But as explained, the issue is that in both instances the different exceptions originate from deserialization and validation in the course of REST Controllers, as pointed out in my initial post. – void Mar 02 '23 at 13:48

1 Answers1

1

There are 2 levels of validation here which first validate on the controller layer , then validate on the bean method.(See this for more details)

Both validations at the end delegate to Bean Validation to validate but calling different validation methods:

  • In controller layer , it calls Validator#validate() and throw MethodArgumentNotValidException if fails
  • In bean method layer , it calls ExecutableValidator#validateParameters() and throw ConstraintViolationException if fails.

For the entities list case, the problem is Bean Validation does not works for Validator#validate(List<TestEntity> testEntities) (p.s. not sure why but I guess it is not supported). So actually no validation happens on the controller layer and the validation finally take place on the bean method layer which throws ConstraintViolationException.

To make Validator#validate(List<TestEntity> testEntities) work , you can wrap the list into a wrapper object . Note that you also need to annotate @Valid in the List 's type argument :

public class TestEntityListWrapper {

    public List<@Valid TestEntity> entities = new ArrayList<>();

    public TestEntityListWrapper(){

    }

    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
    public TestEntityListWrapper(List<TestEntity> entities){
        this.entities = entities;
    }
}

@PostMapping(path = "/test-entities")
public ResponseEntity<Void> saveTestEntity(@Valid @RequestBody TestEntityListWrapper testEntities) {

}

Then it will throw MethodArgumentNotValidException for both case.

Or if you want both of them throw ConstraintViolationExceptions , you can refer to my answers at here to disable the Bean Validation in controller layer :

public class TestController{

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

  @PostMapping(path = "/test-entities")
  public ResponseEntity<Void> saveTestEntity(@Valid @RequestBody List<TestEntity> testEntities) {

  }

}
Ken Chan
  • 84,777
  • 26
  • 143
  • 172
  • Thank you @ken-chan for this in depth response and also the link to another stackoverflow comment that I have missed during my initial search. While I understand now the origins of the behaviour it is still somewhat odd in terms of API Contract provided by Spring in this instance. I will probably raise this as an issue, maybe there is some way for the Spring team to improve or unify behaviour in such instances. – void Mar 15 '23 at 08:56