Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to get query parameter name from ConstraintViolationException

I have a service method:

 @GetMapping(path = "/api/some/path", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> getWhatever(@RequestParam(value = "page-number", defaultValue = "0") @Min(0) Integer pageNumber, ...

If the caller of an API doesn't submit a proper value for page-number query parameter, javax.ConstraintViolationexception is being raised. The message of the exception would read smth like:

getWhatever.pageNumber must be equal or greater than 0

In the response body, I would like to have this message instead:

page-number must be equal or greater than 0

I want my message to have the name of a query parameter, not the name of the argument. IMHO, including the name of the argument is exposing the implementation details.

The problem is, I cannot find an object that is carrying query parameter name. Seems like the ConstraintViolationException doesn't have it.

I am running my app in spring-boot.

Any help would be appreciated.

P.S.: I have been to the other similar threads that claim to solve the problem, none of them actually do in reality.

like image 596
Ihor M. Avatar asked Jun 05 '19 20:06

Ihor M.


3 Answers

Here is how I made it work in spring-boot 2.0.3:

I had to override and disable ValidationAutoConfiguration in spring-boot:

import org.springframework.boot.validation.MessageInterpolatorFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.env.Environment;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;

import javax.validation.Validator;

@Configuration
public class ValidationConfiguration {
    public ValidationConfiguration() {
    }

    @Bean
    public static LocalValidatorFactoryBean validator() {
        LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
        factoryBean.setParameterNameDiscoverer(new CustomParamNamesDiscoverer());
        MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory();
        factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
        return factoryBean;
    }

    @Bean
    public static MethodValidationPostProcessor methodValidationPostProcessor(Environment environment, @Lazy Validator validator) {
        MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
        boolean proxyTargetClass = (Boolean) environment.getProperty("spring.aop.proxy-target-class", Boolean.class, true);
        processor.setProxyTargetClass(proxyTargetClass);
        processor.setValidator(validator);
        return processor;
    }
}

CustomParamNamesDiscoverer sits in the same package and it is a pretty much a copy-paste of DefaultParameterNameDiscoverer, spring-boot's default implementation of param name discoverer:

import org.springframework.core.*;
import org.springframework.util.ClassUtils;

public class CustomParameterNameDiscoverer extends PrioritizedParameterNameDiscoverer {
    private static final boolean kotlinPresent = ClassUtils.isPresent("kotlin.Unit", CustomParameterNameDiscoverer.class.getClassLoader());

    public CustomParameterNameDiscoverer() {
        if (kotlinPresent) {
            this.addDiscoverer(new KotlinReflectionParameterNameDiscoverer());
        }

        this.addDiscoverer(new ReqParamNamesDiscoverer());
        this.addDiscoverer(new StandardReflectionParameterNameDiscoverer());
        this.addDiscoverer(new LocalVariableTableParameterNameDiscoverer());
    }
}

I wanted it to remain pretty much intact (you can see even kotlin checks in there) with the only addition: I am adding an instance of ReqParamNamesDiscoverer to the linked lists of discoverers. Note that the order of addition does matter here.

Here is the source code:

import com.google.common.base.Strings;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.annotation.RequestParam;

import java.lang.reflect.Constructor;
import java.lang.reflect.Executable;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;

public class ReqParamNamesDiscoverer implements ParameterNameDiscoverer {

    public ReqParamNamesDiscoverer() {
    }

    @Override
    @Nullable
    public String[] getParameterNames(Method method) {
        return doGetParameterNames(method);
    }

    @Override
    @Nullable
    public String[] getParameterNames(Constructor<?> constructor) {
        return doGetParameterNames(constructor);
    }

    @Nullable
    private static String[] doGetParameterNames(Executable executable) {
        Parameter[] parameters = executable.getParameters();
        String[] parameterNames = new String[parameters.length];
        for (int i = 0; i < parameters.length; ++i) {
            Parameter param = parameters[i];
            if (!param.isNamePresent()) {
                return null;
            }
            String paramName = param.getName();
            if (param.isAnnotationPresent(RequestParam.class)) {
                RequestParam requestParamAnnotation = param.getAnnotation(RequestParam.class);
                if (!Strings.isNullOrEmpty(requestParamAnnotation.value())) {
                    paramName = requestParamAnnotation.value();
                }
            }
            parameterNames[i] = paramName;
        }
        return parameterNames;
    }
}

If parameter is annotated with RequestParam annotation, I am retrieving the value attribute and return it as a parameter name.

The next thing was disabling auto validation config, somehow, it doesn't work without it. This annotation does the trick though: @SpringBootApplication(exclude = {ValidationAutoConfiguration.class})

Also, you need to have a custom handler for your ConstraintValidationException :

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ConstraintViolationException.class)
    public ErrorDTO handleConstraintViolationException(ConstraintViolationException ex) {
        Map<String, Collection<String>> errors = new LinkedHashMap<>();
        ex.getConstraintViolations().forEach(constraintViolation -> {
            String queryParamPath = constraintViolation.getPropertyPath().toString();
            log.debug("queryParamPath = {}", queryParamPath);
            String queryParam = queryParamPath.contains(".") ?
                    queryParamPath.substring(queryParamPath.indexOf(".") + 1) :
                    queryParamPath;
            String errorMessage = constraintViolation.getMessage();
            Collection<String> perQueryParamErrors = errors.getOrDefault(queryParam, new ArrayList<>());
            perQueryParamErrors.add(errorMessage);
            errors.put(queryParam, perQueryParamErrors);
        });
        return validationException(new ValidationException("queryParameter", errors));
    }

ValidationException stuff is my custom way of dealing with validation errors, in a nutshell, it produces an error DTO, which will be serialized into JSON with all the validation error messages.

like image 102
Ihor M. Avatar answered Nov 08 '22 08:11

Ihor M.


Add a custom message to @Min annotation like this

@Min(value=0, message="page-number must be equal or greater than {value}")
like image 1
Dzmitry Bahdanovich Avatar answered Nov 08 '22 08:11

Dzmitry Bahdanovich


Right now, you cannot do it (well, except if you define a custom message for each annotation but I suppose that's not what you want).

Funnily enough, someone worked recently on something very similar: https://github.com/hibernate/hibernate-validator/pull/1029 .

This work has been merged to the master branch but I haven't released a new 6.1 alpha containing this work yet. It's a matter of days.

That being said, we had properties in mind and now that you ask that, we should probably generalize that to more things, method parameters included.

Now that we have the general idea, it shouldn't be too much work to generalize it, I think.

I'll discuss this with the contributor and the rest of the team and get back to you.

like image 1
Guillaume Smet Avatar answered Nov 08 '22 07:11

Guillaume Smet