Return different HTTP status code from spring boot custom validators

Viewed 4180

I am using spring-boot version:2.0.5

Gradle:

buildscript {
    ext {
        springBootVersion = '2.0.5.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'io.reflectoring'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 11

repositories {
    mavenCentral()
}

dependencies {
    implementation('org.springframework.boot:spring-boot-starter-data-jpa')
    implementation('org.springframework.boot:spring-boot-starter-validation')
    implementation('org.springframework.boot:spring-boot-starter-web')
    runtimeOnly('com.h2database:h2')
    testImplementation('org.springframework.boot:spring-boot-starter-test')
    testImplementation('org.junit.jupiter:junit-jupiter-engine:5.0.1')

    // these dependencies are needed when running with Java 11, since they
    // are no longer part of the JDK
    implementation('javax.xml.bind:jaxb-api:2.3.1')
    implementation('org.javassist:javassist:3.23.1-GA')
}

test{
    useJUnitPlatform()
}

Controller

@RestController
class ValidateRequestBodyController {

  @PostMapping("/validateBody")
  ResponseEntity<String> validateBody(@Valid @RequestBody Input input) {
    return ResponseEntity.ok("valid");
  }

}

Validator class

class InputWithCustomValidator {

  @IpAddress
  private String ipAddress;
  
  // ...

}


class IpAddressValidator implements ConstraintValidator<IpAddress, String> {

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
    Pattern pattern = 
      Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$");
    Matcher matcher = pattern.matcher(value);
    //step 1
    if (!matcher.matches()) {
        return 400;
      }
    //Step 2
      if (ipAddress already in DB) {
        return 409; //conflict with other IP address
      }
      //Also I need to return different exception based on diff validations

  }
}

ControllerAdvice

@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handle(ValidationException e) {
    return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(e.getMessage());
}

    @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(e.getMessage());
}

If I throw customException from my validator then I used to get below error message even though I have corresponding controllerAdvice for it,

{
  "code": "invalid_request"
    "description": "HV000028: Unexpected exception during isValid call."
}

Always, I am getting 400 Bad request since I have a controllerAdvice which always return 400.

What I would like to achive here is, Is there any possibility to return customException with status code or is there anyway to return different status code from validator? I see similar posts in StackOverflow But there were no answer. I also checked other posts but I couldn't find it useful.

4 Answers

Current Behaviour

  • When an exception (which does not extend ConstraintDeclarationException) is thrown by the validator code instead of returning false, javax.validation wraps the exception in ValidationException. This is validator framework's behaviour and not a spring framework issue.

  • When an exception which extends ConstraintDeclarationException is thrown by the validator code instead of returning false, javax.validation framework propagates it.

  • If the validator returns false instead of throwing exception, Spring will convert all the validation errors into global errors or field errors and wrap them in MethodArgumentNotValidException and throws it.

Issue

  • The second option has field errors and global errors, custom status code can only be returned by inspecting the field name and error code. So this is not feasible as many fields can be added with that annotation.
  • In the first option, Custom exceptions which were thrown in the validator are wrapped in ValidationException so exception specific handler is not possible.

Possible Solutions

  • Unwrap the specific exception which does not extend ConstraintDeclarationException and map it
    @ExceptionHandler(ValidationException.class)
    public ResponseEntity handle(ValidationException e) {
        Throwable cause = e.getCause();
        if (cause instanceof InvalidIpException) {
            return ResponseEntity
                    .status(HttpStatus.BAD_REQUEST)//may be different code
                    .body(cause.getMessage());
        }
        if (cause instanceof InuseIpException) {
            return ResponseEntity
                    .status(HttpStatus.BAD_REQUEST)//may be different code
                    .body(cause.getMessage());
        }
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(e.getMessage());
    }
  • Make your specific exception to extend ConstraintDeclarationException and then have specific handler for it.
    public class InvalidIpException extends 
                                    ConstraintDeclarationException {
    @ExceptionHandler(InvalidIpException.class)
    public ResponseEntity handle(InvalidIpException e) {
     ...
    }

Reference code

create a class for Error under the model package

@NoArgsConstructor
@Geeter
@Setter
public class ErrorMessage
{
    private int errorCode;
    private String errorMsg;
    private String documentation;

     public ErrorMessage(int errorCode,String errorMsg,String documentation)
       {
          this.errorCode=errorCode;
          this.errorMsg=errorMsg;
          this.documentation=documentation;
       }
}

create a Custom Validation Exception class under the exception package

public class CustomValidationException extends RuntimeException
{
          public CustomValidationException(String msg)
         {
              super(msg);
         }
}

create ExceptionHandler class under the same(exception) package

@RestControllerAdvice
public class CustomValidationExceptionHandler
{
   @ExceptionHandler
   public ResponseEntity toResponse(CustomValidationException ex)
   {
        ErrorMessage errorMessage=new 
       ErrorMessage(400,ex.getMessage,"www.stackoverflow.com");
      return new ResponseEntity<ErrorMessage>(errorMessage,HttpStatus.BAD_REQUES);
   }

}

I will state my case here and you can apply my solution for your project I guess.

My annotation:

@Target({ ElementType.PARAMETER, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ResourceIdConstraintValidator.class)
public @interface ResourceId {
    String message() default "Resource doesn't exist with given id.";
    HttpStatus responseStatus() default HttpStatus.NOT_FOUND;
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Keep in mind next line from annotation above, this is the key:

HttpStatus responseStatus() default HttpStatus.NOT_FOUND;

My validator:

@Service
@RequiredArgsConstructor
public class ResourceIdConstraintValidator implements ConstraintValidator<ResourceId, Integer> {

    private final BinaryStorageService storageService;

    @Override
    public boolean isValid(Integer id, ConstraintValidatorContext context) {
        return storageService.isResourceExist(id);
    }
}

My Exception handler from my RestControllerAdvice:

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ExceptionResponse> handleConstraintViolationException(
        ConstraintViolationException e,
        HttpServletRequest request
) {
    final List<String> messages = e.getConstraintViolations().stream()
            .map(ConstraintViolation::getMessage)
            .toList();

    final HttpStatus httpStatus = e.getConstraintViolations().stream()
            .findFirst().map(violation ->
                    (HttpStatus) violation.getConstraintDescriptor().getAttributes().get("responseStatus")
            ).orElse(HttpStatus.BAD_REQUEST);

    final ExceptionResponse exceptionResponse = ExceptionResponse.builder()
            .timestamp(ZonedDateTime.now())
            .status(httpStatus.value())
            .error(httpStatus.getReasonPhrase())
            .message(messages)
            .path(request.getServletPath())
            .build();

    return ResponseEntity.status(httpStatus)
            .body(exceptionResponse);
}

So, I solved your issue in:

final HttpStatus httpStatus = e.getConstraintViolations().stream()
        .findFirst().map(violation ->
                (HttpStatus) violation.getConstraintDescriptor().getAttributes().get("responseStatus")
        ).orElse(HttpStatus.BAD_REQUEST);

After understanding the question fully, following is what will give you what you need. You can add as many exceptions as you need in the hierarchy as child classes of IpValidationException and keep custom HTTP status code as per your business/technical use cases.

public abstract class IpValidationException extends ValidationException {
    private HttpStatus status;
    private String message;

    public IpValidationException(HttpStatus status, String message) {
        this.status = status;
        this.message = message;
    }

    public HttpStatus getStatus() {
        return status;
    }

    public String getMessage() {
        return message;
    }
}

public class InvalidIpException extends IpValidationException {
    public InvalidIpException(HttpStatus status, String message) {
        super(HttpStatus.BAD_REQUEST, "Invalid IP address");
    }
}

public class InuseIpException extends IpValidationException {
    public InuseIpException(HttpStatus status, String message) {
        super(HttpStatus.CONFLICT, "IP address already in use");
    }
}

public class IpAddressValidator implements ConstraintValidator<IpAddress, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        Pattern pattern =
                Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$");
        Matcher matcher = pattern.matcher(value);
        //step 1
        if (!matcher.matches()) {
            throw new InvalidIpException();
        }
        //Step 2
        if (ipAddress already in DB) {
            throw new InuseIpException(); //conflict with other IP address
        }
        //Add more checks with more exception as you need.

    }
}

// This is how your exception handler should look like
@ExceptionHandler(IpValidationException.class)
public ResponseEntity<ErrorResponse> handle(IpValidationException e) {
    return ResponseEntity
            .status(e.getStatus())
            .body(e.getMessage());
}

Hope this helps!!

Related