Garam Choi
Garam Choi

Reputation: 149

How to throw custom exception in proper way when using @javax.validation.Valid?

How to throw a custom exception in the proper way when using @javax.validation.Valid?

I'm using @Valid in controller, and @AssertTrue to validate request body fields.

public ResponseEntity<Foo> createFoo(
    @Valid @RequestBody Foo FooRequest ...
    @AssertTrue()
    public boolean isFooValid() {
        if (invalid)
            return false;
        ...
    }

However, I want to throw customized Exception class in some condition.

    @AssertTrue()
    public boolean isFooValid() {
        if (invalid)
            return false;
        ...

        // note below
        if (invalidInAnotherCondition)
            throw new CustomizedException(...);
    }

I know this is not desirable way to utilize @Valid in controller, and @AssertTrue. Nevertheless, as I can make my own Exception class which contains customized error info, with the convenience of @Valid.

However the error happens.

javax.validation.ValidationException: HV000090: Unable to access isFooValid
    at org.hibernate.validator.internal.util.ReflectionHelper.getValue(ReflectionHelper.java:245)
    at org.hibernate.validator.internal.metadata.location.GetterConstraintLocation.getValue(GetterConstraintLocation.java:89)
    at org.hibernate.validator.internal.engine.ValueContext.getValue(ValueContext.java:235)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateMetaConstraint(ValidatorImpl.java:549)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForSingleDefaultGroupElement(ValidatorImpl.java:515)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForDefaultGroup(ValidatorImpl.java:485)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateConstraintsForCurrentGroup(ValidatorImpl.java:447)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validateInContext(ValidatorImpl.java:397)
    at org.hibernate.validator.internal.engine.ValidatorImpl.validate(ValidatorImpl.java:173)
    at org.springframework.validation.beanvalidation.SpringValidatorAdapter.validate(SpringValidatorAdapter.java:117)
    at org.springframework.boot.autoconfigure.validation.ValidatorAdapter.validate(ValidatorAdapter.java:70)
    at org.springframework.validation.DataBinder.validate(DataBinder.java:889)
    at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.validateIfApplicable(AbstractMessageConverterMethodArgumentResolver.java:266)
    at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.resolveArgument(RequestResponseBodyMethodProcessor.java:137)
    at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)
    at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:167)
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:134)
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:106)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:888)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:793)
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040)
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943)
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
    at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:523)
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:590)
    at io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129)
    at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84)
    at io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62)
    at io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68)
    at io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36)
    at io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68)
    at io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:132)
    at io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46)
    at io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64)
    at io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60)
    at io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:77)
    at io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:269)
    at io.undertow.servlet.handlers.ServletInitialHandler.access$100(ServletInitialHandler.java:78)
    at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:133)
    at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:130)
    at io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48)
    at io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43)
    at io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:249)
    at io.undertow.servlet.handlers.ServletInitialHandler.access$000(ServletInitialHandler.java:78)
    at io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:99)
    at io.undertow.server.Connectors.executeRootHandler(Connectors.java:376)
    at io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:830)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
    at java.base/java.lang.Thread.run(Thread.java:834)
Caused by: java.lang.reflect.InvocationTargetException: null
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.hibernate.validator.internal.util.ReflectionHelper.getValue(ReflectionHelper.java:242)
    ... 70 common frames omitted
Caused by: com.finda.services.finda.common.exception.CustomizedException: 'df282e0d-1205-4574-adaa-0af819af66c0' 
    at ...
    ... 75 common frames omitted

I think this happens because originally, @AssertTrue throws its own Exception itself and it is to be processed through the internal logic; However, customized thrown Exception is not acceptable which can be seen in Caused by: java.lang.reflect.InvocationTargetException: null and javax.validation.ValidationException: HV000090: Unable to access isFooValid

So my final question is below,

Can I bypass this error, still throwing customized Exception?

I really appreciate that you read this long posting in advance.

Upvotes: 12

Views: 35839

Answers (3)

Universe Whole-Xuan
Universe Whole-Xuan

Reputation: 123

step 1.add the custom exception method in your annotation:

@Documented
@Constraint(validatedBy = {})
@Target({ FIELD, TYPE_USE,RECORD_COMPONENT })
@Retention(RUNTIME)
public @interface ValidIdNumber {
    String message() default "The '${validatedValue}' not a valid identification number!";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
    
    Class<? extends Throwable> exception() default InvalidIdNumberException.class;
}

step 2.add AbstractValidator

import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.util.Arrays;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.function.Consumer;

import javax.validation.Validation;
import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import javax.validation.metadata.ConstraintDescriptor; 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 

public abstract class AbstractValidator<T> implements Consumer<ConstraintViolation<T>> {
    public AbstractValidator(){
        
    }
    private static final Logger LOGGER= LoggerFactory.getLogger(AbstractValidator.class);
    private static class ExceptionFactory {
        @SuppressWarnings("unchecked")
        private static <A extends Annotation, E extends Throwable> Class<E> getExceptionTypeFromAnnotation(
                ConstraintDescriptor<A> constraintDescriptor) throws E {
            Annotation annotation = constraintDescriptor.getAnnotation();
            Class<A> type = (Class<A>) annotation.annotationType();
            Class<E> e = Arrays.stream(type.getDeclaredMethods()).filter(method -> {
                Type genericReturnType = method.getGenericReturnType();
                if (genericReturnType instanceof ParameterizedType parameterizedType) {
                    if (parameterizedType.getRawType().equals(Class.class)) {
                        Type actualTypeArgument = parameterizedType.getActualTypeArguments()[0];
                        if (actualTypeArgument instanceof WildcardType wildcardType) {
                            var upperBounds = wildcardType.getUpperBounds();
                            if (upperBounds.length > 0) {
                                Class<?> clazz = (Class<?>) upperBounds[0];
                                return Throwable.class.isAssignableFrom(clazz);
                            }
                        }
                    }
                }
                return false;
            }).map(method -> {
                try {
                    return (Class<E>) method.invoke(annotation);
                } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e1) {
                    LOGGER.error(e1.getMessage(), e1); 
                    return null;
                }
            }).findFirst().orElseThrow();
            return e;
        }

        public static <A extends Annotation, E extends Throwable> E create(ConstraintDescriptor<A> constraintDescriptor,
                String message) throws NoSuchElementException, E {
            Class<E> e = getExceptionTypeFromAnnotation(constraintDescriptor);
            try {
                return e.getConstructor(String.class).newInstance(message);
            } catch (InstantiationException | IllegalAccessException | IllegalArgumentException
                    | InvocationTargetException | NoSuchMethodException | SecurityException e1) {
                LOGGER.error(e1.getMessage(), e1);
                e1.printStackTrace();
                return null;
            }
        }
    }
 
    @Override
    public void accept(ConstraintViolation<T> constraintViolation) throws IllegalArgumentException {
        switch (constraintViolation.getPropertyPath().toString()) {
        default -> throw new IllegalArgumentException(
                constraintViolation.getPropertyPath().toString() + " " + constraintViolation.getMessage());
        }
    }

    public <E extends Throwable> void validate(T obj, Class<?>... groups) throws E {
        Set<ConstraintViolation<T>> set = Validation.buildDefaultValidatorFactory().getValidator().validate(obj, groups);
        if (!set.isEmpty()) {
            ConstraintViolation<T> constraintViolation = set.iterator().next();
            String message = constraintViolation.getMessage();
            try {
                throw ExceptionFactory.create(constraintViolation.getConstraintDescriptor(), message);
            } catch (NoSuchElementException e) {  
                accept(constraintViolation);
            }
        }
    }
}

step 3.add a impl of AbstractValidator:

public class ValidatorImpl extends AbstractValidator<Object>{
}

step 4.test:

 record DTO(@ValidIdNumber String idNumber){} 
    public static void main(String[] args) throws InvalidIdNumberException{
        DTO dto=new DTO("1234");
        new ValidatorImpl().validate();
    }

source in github

Upvotes: 0

CHH
CHH

Reputation: 168

Here is a solution I used (that might not answer this question directly but possibly help others who get to this page that came with similar intentions as I had):

My aim was foremost to respond with a custom error message to the client who sent a request with an invalid object.

  1. In my controller I use a standard @Valid annotation from javax.validation.Valid before the parameter
  2. In my entity class I use the standard validation constraint, e.g. @NotNull(message = "Field XYZ has to be provided") from javax.validation.constraints.NotNull
  3. I catch the ValidationException in my ControllerAdvice class:
@ControllerAdvice 
public class MyControllerAdvice extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException exception, 
            HttpHeaders headers, 
            HttpStatus status, 
            WebRequest request) {
        return new ResponseEntity<>(
                new JSONObject().put("message", extractValidationMessage(exception)).toString(),
                HttpStatus.BAD_REQUEST);
    }

    private String extractValidationMessage(MethodArgumentNotValidException exception) {
        String exceptionMessage = exception.getMessage();
        String[] messageParts = exceptionMessage.split(";");
        String finalPart = messageParts[messageParts.length -1];

        return finalPart.trim().replaceAll("default message \\[|]]","");
    } 
}
  1. This will return a 400 Error code and a body of this style:

{"message":"Field XYZ has to be provided"}

Upvotes: 3

Edwin Dalorzo
Edwin Dalorzo

Reputation: 78579

Consider the example below where I implemented something like what you asking for:

@RestController
@RequestMapping("/accounts")
public class SavingsAccountController {

   private final BankAccountService accountService;

   @Autowired
   public SavingsAccountController(SavingsAccountService accountService) {
       this.accountService = accountService;
   }

   @PutMapping("withdraw")
   public ResponseEntity<AccountBalance> onMoneyWithdrawal(@RequestBody @Validated WithdrawMoney withdrawal, BindingResult errors) {

       //this is the validation barrier
       if (errors.hasErrors()) {
           throw new ValidationException(errors);
       }

       double balance = accountService.withdrawMoney(withdrawal);
       return ResponseEntity.ok(new AccountBalance(
               withdrawal.getAccountNumber(), balance));
   }

   @PutMapping("save")
   public ResponseEntity<AccountBalance> onMoneySaving(@RequestBody @Validated SaveMoney savings, BindingResult errors) {

       //this is the validation barrier
       if (errors.hasErrors()) {
           throw new ValidationException(errors);
       }

       double balance = accountService.saveMoney(savings);
       return ResponseEntity.ok(new AccountBalance(
               savings.getAccountNumber(), balance));
   }
}

In the code above, we're using Bean Validation to check that the user's DTO contains valid information. Any errors found in the DTO are provided through the BindingResult errors variable, from where the developer can extract all the details of what went wrong during the validation phase.

To make it easier for the developers to deal with this pattern, in the code above, I simply wrap the BindingResult into a custom ValidationException which knows how to extract the validation error details.

public class ValidationException extends RuntimeException {

   private final BindingResult errors;

   public ValidationException(BindingResult errors) {
       this.errors = errors;
   }

   public List<String> getMessages() {
       return getValidationMessage(this.errors);
   }


   @Override
   public String getMessage() {
       return this.getMessages().toString();
   }


   //demonstrate how to extract a message from the binging result
   private static List<String> getValidationMessage(BindingResult bindingResult) {
       return bindingResult.getAllErrors()
               .stream()
               .map(ValidationException::getValidationMessage)
               .collect(Collectors.toList());
   }

   private static String getValidationMessage(ObjectError error) {
       if (error instanceof FieldError) {
           FieldError fieldError = (FieldError) error;
           String className = fieldError.getObjectName();
           String property = fieldError.getField();
           Object invalidValue = fieldError.getRejectedValue();
           String message = fieldError.getDefaultMessage();
           return String.format("%s.%s %s, but it was %s", className, property, message, invalidValue);
       }
       return String.format("%s: %s", error.getObjectName(), error.getDefaultMessage());
   }

}

Notice that in my controller definition I do not use Bean Validation's @Valid annotation, but the Spring counterpart @Validated, but under the hood Spring will use Bean Validation.

How to Serialize the Custom Exception?

In the code above the ValidationException will be thrown when the payload is invalid. How should the controller create a response for the client out of this?

There are multiple ways to deal with this, but perhaps the simplest solution is to define a class annotated as @ControllerAdvice. In this annotated class we will place our exception handlers for any specific exception that we want to handle and turn them into a valid response object to travel back to our clients:

@ControllerAdvice
public class ExceptionHandlers {

   @ExceptionHandler
   public ResponseEntity<ErrorModel> handle(ValidationException ex) {
       return ResponseEntity.badRequest()
                            .body(new ErrorModel(ex.getMessages()));
   }

   //...
}

I wrote a few other examples of this and other validation techniques with Spring in case you may be interested in reading more about it.

Upvotes: 14

Related Questions