How to get query parameter name from ConstraintViolationException
Asked Answered
S

4

7

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.

Smiga answered 5/6, 2019 at 20:50 Comment(0)
S
5

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.

Smiga answered 6/6, 2019 at 18:29 Comment(3)
Thank you Guillaume Smet and @Gunnar for your help, for guiding me in the right direction.Smiga
"queryParam" is not working with nested objects. I would suggest to replace it with regex. String queryParam = queryParamPath.replaceAll(".*\\.", "");Transcript
You could simplify your ValidationConfiguration. a) The methodValidationPostProcessor method is not required. If not defined, the default autoconfiguration kicks in. b) Yu can build on the default implementation of the validator autoconfiguration in the validator method. var factoryBean = ValidationAutoConfiguration.defaultValidator(applicationContext); factoryBean.setParameterNameDiscoverer(new CustomParamNamesDiscoverer()); return factoryBean;Divergency
W
1

Add a custom message to @Min annotation like this

@Min(value=0, message="page-number must be equal or greater than {value}")
Whaling answered 5/6, 2019 at 20:57 Comment(2)
Yeah but I think that's exactly what he wants to avoid.Flannelette
Yeah, it is a viable solution, but I have approx 50 service classes, each of them having a multitude of constraints. This would clutter the code base and may introduce an error. I want to automate it. Also, if someone changes the name of the param and forgets to update the message, there will be a disconnect.Smiga
F
1

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.

Flannelette answered 5/6, 2019 at 20:59 Comment(10)
Thanks for your input!Smiga
There already is the ParameterNameProvider contract which could be implemented to obtain the name from the @RequestParam annotation. There'd still be the method name in the message, though.Roselinerosella
Could you please guide me a little or send me a sample implementation?Smiga
Method name in the message is fine, I can easily remove it by inspecting a string.Smiga
See the spec for the actual contract and ways of registering your implementation. In a nutshell, you'd fetch that @RequestParam annotation from the parameters of validated methods using reflection and return the list of names. You might fall back to the default behaviour (actual parameter names) in case the annotation isn't present.Roselinerosella
@Roselinerosella how do I register my implementation of ParameterNameProvider in the framework?Smiga
import javax.validation.Validation; import javax.validation.Validator; import javax.validation.ValidatorFactory; @Configuration public class ValidationConfiguration { @Bean public Validator validator() { final ValidatorFactory validatorFactory = Validation.byDefaultProvider() .configure() .parameterNameProvider(new ReqParamNamesAwareProvider()) .buildValidatorFactory(); return validatorFactory.getValidator(); } }Smiga
LGTM, this should work. You also could try to register it via validation.xml.Roselinerosella
It didn't work, so I thought maybe I need to add ` @Primary` annotation to my bean and the app suddenly started failing: Caused by: org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'javax.validation.Validator' available: more than one 'primary' bean found among candidates: [defaultValidator, validator] I am wondering now about this defaultValidator where it comes from and how I can override its behavior. My app is also using hibernate.Smiga
I did some more investigation and defaultValidator comes form ValidationAutoConfiguration.class. I had to disable it using annotation: @SpringBootApplication(exclude = {ValidationAutoConfiguration.class}) I have copied the contents of it, so that spring-boot works as expected. The only thing I need to do is update that ParameterNameProvider, but the object that is being returned is LocalValidatorFactoryBean. This class doesn't have a notion of ParameterNameProvider, but instead using ParameterNameDiscoverer. Super confusing.Smiga
K
1

I don't think getting the name of the query parameter is possible but would like to be proven wrong if somebody knows a way.

As Dmitry Bogdanovich says, having a custom message is the easiest and only way I know how to do something close to what you need. If you say you don't want to clutter your code with these messages, you can just do this:

Add a ValidationMessages.properties file in your resources folder. Here you can just say:

page_number.min=page-number must be equal or greater than {value}

Now you can use the annotation and write:

@Min(value = 0, message = "{page_number.min}")

This way you have a single source to change anything about the message when needed.

Kincardine answered 6/6, 2019 at 10:8 Comment(3)
Read the comments of the other answerSmiga
I did read those comments and I was simply giving you an easy way of having all those messages in one place where you could change the parameters name if your RequestParameter changed. As I understand it there isn't a way to do it your way right now and I thought this could be a workaround.Kincardine
I think there is s way and once I prove it works, I’ll post my solution hereSmiga

© 2022 - 2024 — McMap. All rights reserved.