Spring Boot : Custom Validation in Request Params

Abhishek Anand picture Abhishek Anand · Dec 20, 2019 · Viewed 17k times · Source

I want to validate one of the request parameters in my controller . The request parameter should be from one of the list of given values , if not , an error should be thrown . In the below code , I want the request param orderBy to be from the list of values present in @ValuesAllowed.

@RestController
@RequestMapping("/api/opportunity")
@Api(value = "Opportunity APIs")
@ValuesAllowed(propName = "orderBy", values = { "OpportunityCount", "OpportunityPublishedCount", "ApplicationCount",
        "ApplicationsApprovedCount" })
public class OpportunityController {

@GetMapping("/vendors/list")
    @ApiOperation(value = "Get all vendors")

    public ResultWrapperDTO getVendorpage(@RequestParam(required = false) String term,
            @RequestParam(required = false) Integer page, @RequestParam(required = false) Integer size,
            @RequestParam(required = false) String orderBy, @RequestParam(required = false) String sortDir) {

I have written a custom bean validator but somehow this is not working . Even if am passing any random values for the query param , its not validating and throwing an error.

@Repeatable(ValuesAllowedMultiple.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ValuesAllowedValidator.class})
public @interface ValuesAllowed {

    String message() default "Field value should be from list of ";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String propName();
    String[] values();
}
public class ValuesAllowedValidator implements ConstraintValidator<ValuesAllowed, Object> {

    private String propName;
    private String message;
    private String[] values;

    @Override
    public void initialize(ValuesAllowed requiredIfChecked) {
        propName = requiredIfChecked.propName();
        message = requiredIfChecked.message();
        values = requiredIfChecked.values();
    }

    @Override
    public boolean isValid(Object object, ConstraintValidatorContext context) {
        Boolean valid = true;
        try {
            Object checkedValue = BeanUtils.getProperty(object, propName);

            if (checkedValue != null) {
                valid = Arrays.asList(values).contains(checkedValue.toString().toLowerCase());
            } 

            if (!valid) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(message.concat(Arrays.toString(values)))
                        .addPropertyNode(propName).addConstraintViolation();
            }
        } catch (IllegalAccessException e) {
            log.error("Accessor method is not available for class : {}, exception : {}", object.getClass().getName(), e);
            return false;
        } catch (NoSuchMethodException e) {
            log.error("Field or method is not present on class : {}, exception : {}", object.getClass().getName(), e);
            return false;
        } catch (InvocationTargetException e) {
            log.error("An exception occurred while accessing class : {}, exception : {}", object.getClass().getName(), e);
            return false;
        }
        return valid;
    }
}

Answer

Czolg picture Czolg · Dec 24, 2019

You would have to change few things for this validation to work.

Controller should be annotated with @Validated and @ValuesAllowed should annotate the target parameter in method.

import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Validated
@RestController
@RequestMapping("/api/opportunity")
public class OpportunityController {

    @GetMapping("/vendors/list")
    public String getVendorpage(
            @RequestParam(required = false)
            @ValuesAllowed(values = {
                    "OpportunityCount",
                    "OpportunityPublishedCount",
                    "ApplicationCount",
                    "ApplicationsApprovedCount"
            }) String orderBy,
            @RequestParam(required = false) String term,
            @RequestParam(required = false) Integer page, @RequestParam(required = false) Integer size,
            @RequestParam(required = false) String sortDir) {
        return "OK";
    }
}

@ValuesAllowed should target ElementType.PARAMETER and in this case you no longer need propName property because Spring will validate the desired param.

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ValuesAllowedValidator.class})
public @interface ValuesAllowed {

    String message() default "Field value should be from list of ";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String[] values();
}

Validator:

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Arrays;
import java.util.List;

public class ValuesAllowedValidator implements ConstraintValidator<ValuesAllowed, String> {

    private List<String> expectedValues;
    private String returnMessage;

    @Override
    public void initialize(ValuesAllowed requiredIfChecked) {
        expectedValues = Arrays.asList(requiredIfChecked.values());
        returnMessage = requiredIfChecked.message().concat(expectedValues.toString());
    }

    @Override
    public boolean isValid(String testValue, ConstraintValidatorContext context) {
        boolean valid = expectedValues.contains(testValue);

        if (!valid) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(returnMessage)
                    .addConstraintViolation();
        }
        return valid;
    }
}

But the code above returns HTTP 500 and pollutes logs with ugly stacktrace. To avoid it, you can put such @ExceptionHandler method in controller body (so it will be scoped only to this controller) and you gain control over HTTP status:

@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
String handleConstraintViolationException(ConstraintViolationException e) {
    return "Validation error: " + e.getMessage();
}

... or you can put this method to the separate @ControllerAdvice class and have even more control over this validation like using it across all the controllers or only desired ones.