Spring Boot binding and validation error handling in REST controller

69,480

Solution 1

This is the code what i have used in one of my project for validating REST api in spring boot,this is not same as you demanded,but is identical.. check if this helps

@RequestMapping(value = "/person/{id}",method = RequestMethod.PUT)
@ResponseBody
public Object updatePerson(@PathVariable Long id,@Valid Person p,BindingResult bindingResult){
    if (bindingResult.hasErrors()) {
        List<FieldError> errors = bindingResult.getFieldErrors();
        List<String> message = new ArrayList<>();
        error.setCode(-2);
        for (FieldError e : errors){
            message.add("@" + e.getField().toUpperCase() + ":" + e.getDefaultMessage());
        }
        error.setMessage("Update Failed");
        error.setCause(message.toString());
        return error;
    }
    else
    {
        Person person = personRepository.findOne(id);
        person = p;
        personRepository.save(person);
        success.setMessage("Updated Successfully");
        success.setCode(2);
        return success;
    }

Success.java

public class Success {
int code;
String message;

public int getCode() {
    return code;
}

public void setCode(int code) {
    this.code = code;
}

public String getMessage() {
    return message;
}

public void setMessage(String message) {
    this.message = message;
}
}

Error.java

public class Error {
int code;
String message;
String cause;

public int getCode() {
    return code;
}

public void setCode(int code) {
    this.code = code;
}

public String getMessage() {
    return message;
}

public void setMessage(String message) {
    this.message = message;
}

public String getCause() {
    return cause;
}

public void setCause(String cause) {
    this.cause = cause;
}

}

You can also have a look here : Spring REST Validation

Solution 2

Usually when Spring MVC fails to read the http messages (e.g. request body), it will throw an instance of HttpMessageNotReadableException exception. So, if spring could not bind to your model, it should throw that exception. Also, if you do NOT define a BindingResult after each to-be-validated model in your method parameters, in case of a validation error, spring will throw a MethodArgumentNotValidException exception. With all this, you can create ControllerAdvice that catches these two exceptions and handles them in your desirable way.

@ControllerAdvice(annotations = {RestController.class})
public class UncaughtExceptionsControllerAdvice {
    @ExceptionHandler({MethodArgumentNotValidException.class, HttpMessageNotReadableException.class})
    public ResponseEntity handleBindingErrors(Exception ex) {
        // do whatever you want with the exceptions
    }
}

Solution 3

You can't get BindException with @RequestBody. Not in the controller with an Errors method parameter as documented here:

Errors, BindingResult For access to errors from validation and data binding for a command object (that is, a @ModelAttribute argument) or errors from the validation of a @RequestBody or @RequestPart arguments. You must declare an Errors, or BindingResult argument immediately after the validated method argument.

It states that for @ModelAttribute you get binding AND validation errors and for your @RequestBody you get validation errors only.

https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-ann-methods

And it was discussed here:

https://github.com/spring-projects/spring-framework/issues/11406?jql=text%2520~%2520%2522RequestBody%2520binding%2522

For me it still does not make sense from a user point of view. It is often very important to get the BindExceptions to show the user a proper error message. The argument is, you should do client side validation anyway. But this is not true if a developer is using the API directly.

And imagine your client side validation is based on an API request. You want to check if a given date is valid based on a saved calendar. You send the date and time to the backend and it just fails.

You can modify the exception you get with an ExceptionHAndler reacting on HttpMessageNotReadableException, but with this exception I do not have proper access to which field was throwing the error as with a BindException. I need to parse the exception message to get access to it.

So I do not see any solution, which is kind of bad because with @ModelAttribute it is so easy to get binding AND validation errors.

Solution 4

I've given up on this; it is just not possible to get the binding errors using @RequestBody without a lot of custom code. This is different from controllers binding to plain JavaBeans arguments because @RequestBody uses Jackson to bind instead of the Spring databinder.

See https://jira.spring.io/browse/SPR-6740?jql=text%20~%20%22RequestBody%20binding%22

Solution 5

One of the main blocker for solving this problem is the default eagerly-failing nature of the jackson data binder; one would have to somehow convince it to continue parsing instead of just stumble at first error. One would also have to collect these parsing errors in order to ultimately convert them to BindingResult entries. Basically one would have to catch, suppress and collect parsing exceptions, convert them to BindingResult entries then add these entries to the right @Controller method BindingResult argument.

The catch & suppress part could be done by:

  • custom jackson deserializers which would simply delegate to the default related ones but would also catch, suppress and collect their parsing exceptions
  • using AOP (aspectj version) one could simply intercept the default deserializers parsing exceptions, suppress and collect them
  • using other means, e.g. appropriate BeanDeserializerModifier, one could also catch, suppress and collect the parsing exceptions; this might be the easiest approach but requires some knowledge about this jackson specific customization support

The collecting part could use a ThreadLocal variable to store all necessary exceptions related details. The conversion to BindingResult entries and the addition to the right BindingResult argument could be pretty easily accomplished by an AOP interceptor on @Controller methods (any type of AOP, Spring variant including).

What's the gain

By this approach one gets the data binding errors (in addition to the validation ones) into the BindingResult argument the same way as would expect for getting them when using an e.g. @ModelAttribute. It will also work with multiple levels of embedded objects - the solution presented in the question won't play nice with that.

Solution Details (custom jackson deserializers approach)

I created a small project proving the solution (run the test class) while here I'll just highlight the main parts:

/**
* The logic for copying the gathered binding errors 
* into the @Controller method BindingResult argument.
* 
* This is the most "complicated" part of the project.
*/
@Aspect
@Component
public class BindingErrorsHandler {
    @Before("@within(org.springframework.web.bind.annotation.RestController)")
    public void logBefore(JoinPoint joinPoint) {
        // copy the binding errors gathered by the custom
        // jackson deserializers or by other means
        Arrays.stream(joinPoint.getArgs())
                .filter(o -> o instanceof BindingResult)
                .map(o -> (BindingResult) o)
                .forEach(errors -> {
                    JsonParsingFeedBack.ERRORS.get().forEach((k, v) -> {
                        errors.addError(new FieldError(errors.getObjectName(), k, v, true, null, null, null));
                    });
                });
        // errors copied, clean the ThreadLocal
        JsonParsingFeedBack.ERRORS.remove();
    }
}

/**
 * The deserialization logic is in fact the one provided by jackson,
 * I only added the logic for gathering the binding errors.
 */
public class CustomIntegerDeserializer extends StdDeserializer<Integer> {
    /**
    * Jackson based deserialization logic. 
    */
    @Override
    public Integer deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        try {
            return wrapperInstance.deserialize(p, ctxt);
        } catch (InvalidFormatException ex) {
            gatherBindingErrors(p, ctxt);
        }
        return null;
    }

    // ... gatherBindingErrors(p, ctxt), mandatory constructors ...
}

/**
* A simple classic @Controller used for testing the solution.
*/
@RestController
@RequestMapping("/errormixtest")
@Slf4j
public class MixBindingAndValidationErrorsController {
    @PostMapping(consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Level1 post(@Valid @RequestBody Level1 level1, BindingResult errors) {
    // at the end I show some BindingResult logging for a @RequestBody e.g.:
    // {"nr11":"x","nr12":1,"level2":{"nr21":"xx","nr22":1,"level3":{"nr31":"xxx","nr32":1}}}
    // ... your whatever logic here ...

With these you'll get in BindingResult something like this:

Field error in object 'level1' on field 'nr12': rejected value [1]; codes [Min.level1.nr12,Min.nr12,Min.java.lang.Integer,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [level1.nr12,nr12]; arguments []; default message [nr12],5]; default message [must be greater than or equal to 5]
Field error in object 'level1' on field 'nr11': rejected value [x]; codes []; arguments []; default message [null]
Field error in object 'level1' on field 'level2.level3.nr31': rejected value [xxx]; codes []; arguments []; default message [null]
Field error in object 'level1' on field 'level2.nr22': rejected value [1]; codes [Min.level1.level2.nr22,Min.level2.nr22,Min.nr22,Min.java.lang.Integer,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [level1.level2.nr22,level2.nr22]; arguments []; default message [level2.nr22],5]; default message [must be greater than or equal to 5]

where the 1th line is determined by a validation error (setting 1 as the value for a @Min(5) private Integer nr12;) while the 2nd is determined by a binding one (setting "x" as value for a @JsonDeserialize(using = CustomIntegerDeserializer.class) private Integer nr11;). 3rd line tests binding errors with embedded objects: level1 contains a level2 which contains a level3 object property.

Note how other approaches could simply replace the usage of custom jackson deserializers while keeping the rest of the solution (AOP, JsonParsingFeedBack).

Share:
69,480
Jaap van Hengstum
Author by

Jaap van Hengstum

Updated on July 09, 2022

Comments

  • Jaap van Hengstum
    Jaap van Hengstum almost 2 years

    When I have the following model with JSR-303 (validation framework) annotations:

    public enum Gender {
        MALE, FEMALE
    }
    
    public class Profile {
        private Gender gender;
    
        @NotNull
        private String name;
    
        ...
    }
    

    and the following JSON data:

    { "gender":"INVALID_INPUT" }
    

    In my REST controller, I want to handle both the binding errors (invalid enum value for gender property) and validation errors (name property cannot be null).

    The following controller method does NOT work:

    @RequestMapping(method = RequestMethod.POST)
    public Profile insert(@Validated @RequestBody Profile profile, BindingResult result) {
        ...
    }
    

    This gives com.fasterxml.jackson.databind.exc.InvalidFormatException serialization error before binding or validation takes place.

    After some fiddling, I came up with this custom code which does what I want:

    @RequestMapping(method = RequestMethod.POST)
    public Profile insert(@RequestBody Map values) throws BindException {
    
        Profile profile = new Profile();
    
        DataBinder binder = new DataBinder(profile);
        binder.bind(new MutablePropertyValues(values));
    
        // validator is instance of LocalValidatorFactoryBean class
        binder.setValidator(validator);
        binder.validate();
    
        // throws BindException if there are binding/validation
        // errors, exception is handled using @ControllerAdvice.
        binder.close(); 
    
        // No binding/validation errors, profile is populated 
        // with request values.
    
        ...
    }
    

    Basically what this code does, is serialize to a generic map instead of model and then use custom code to bind to model and check for errors.

    I have the following questions:

    1. Is custom code the way to go here or is there a more standard way of doing this in Spring Boot?
    2. How does the @Validated annotation work? How can I make my own custom annotation that works like @Validated to encapsulate my custom binding code?
  • Jaap van Hengstum
    Jaap van Hengstum over 8 years
    Disadvantage here is that you don't get the BindingResult when a binding error occurs. I.e. you can do ex.getBindingResult() on MethodArgumentNotValidException exception, but not on a HttpMessageNotReadableException exception.
  • Ali Dehghani
    Ali Dehghani over 8 years
    The latter seems reasonable, because when binding is failing, we could not have a binding result. There is no binding.
  • Jaap van Hengstum
    Jaap van Hengstum over 8 years
    In my view binding errors like putting a String in a int field or a wrong Enum value should be treated as validation errors. Using DataBinder standalone also binding field errors are in the BindingResult so the service can return a more detailed error response.
  • Kevin M
    Kevin M almost 7 years
    I agree. I'm having an issue trying to properly show a GOOD error message when someone provides an invalid value for an enum. Jackson wraps it and I end up with a very generic HttpMessageNotReadableException. The message in there includes java package and class information in it. Not acceptable. I want to know the field that failed and why, but I can't find any way to do that. I've tried turning off the WRAP_EXCEPTIONS setting but that doesn't seem to have any effect
  • Janning
    Janning over 5 years
    But you can't get the BindException if teh user enters "a" for a field of integer.
  • Adrian
    Adrian almost 5 years
    This won't work with @RequestBody e.g when receiving some JSON - otherwise a very common case for REST based web services (also the point of the question).