How to validate an attribute based on another in spring-boot in a clean way?
Asked Answered
P

3

11

I am validating REST service request/bean in a spring-boot 2.3.1.RELEASE web application. Currently, I am using Hibernate Validator, though I am open to using any other way for validation.

Say, I have a model Foo, which I receive as a request in a Rest Controller. And I want to validate if completionDate is not null then status should be either "complete" or "closed".

@StatusValidate
public class Foo {
    private String status;
    private LocalDate completionDate;
    // getters and setters
}

I created a custom class level annotation @StatusValidate.

@Constraint(validatedBy = StatusValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface StatusValidate {

    String message() default "default status error";

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

    Class<? extends Payload>[] payload() default {};
}

I created StatusValidator class.

public class StatusValidator implements ConstraintValidator<StatusValidate, Foo> {

    @Override
    public void initialize(StatusValidateconstraintAnnotation) {
    }

    @Override
    public boolean isValid(Foovalue, ConstraintValidatorContext context) {
        if (null != value.getCompletionDate() && (!value.getStatus().equalsIgnoreCase("complete") && !value.getStatus().equalsIgnoreCase("closed"))) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate()).
                    .addPropertyNode("status").addConstraintViolation();
            return false;
        }
        return true;
    }
}

When I validate Foo object (by using @Valid or @Validated or manually calling the validator.validate() method), I get following data in the ConstraintViolation. Code:

// Update.class is a group
Set<ConstraintViolation<Foo>> constraintViolations = validator.validate(foo, Update.class);
constraintViolations.forEach(constraintViolation -> {
    ErrorMessage errorMessage = new ErrorMessage();
    errorMessage.setKey(constraintViolation.getPropertyPath().toString());
    errorMessage.setValue(constraintViolation.getInvalidValue());
    // Do something with errorMessage here
});

constraintViolation.getPropertyPath().toString() => status

constraintViolation.getInvalidValue() => (Foo object)

How can I set an invalid value (actual value of status attribute) in custom ConstraintValidator or anywhere else so that constraintViolation.getInvalidValue() returns value of status attribute? OR Is there a better way of validating request payload/bean where validation of an attribute depends on another attribute's value?

Edit : I can do something like

if(constraintViolation.getPropertyPath().toString().equals("status")) {
    errorMessage.setValue(foo.getStatus());
}

but this would involve maintaining the String constant of attribute names somewhere for eg. "status". Though, in the StatusValidator also, I am setting the attribute name .addPropertyNode("status") which also I would like to avoid.


Summary : I am looking for a solution (not necessarily using custom validations or hibernate validator) where

  1. I can validate json requestor or a bean, for an attribute whose validations depends on values of other attributes.
  2. I don't have to maintain bean attribute names as String constants anywhere (maintenance nightmare).
  3. I am able to get the invalid property name and value both.
Politico answered 19/7, 2020 at 13:52 Comment(6)
Did you try checking constraintViolation.getInvalidValue() is instantOf Foo, and cast it and get the value of status?Runabout
Thank you for the comment. I can do this but I will be having multiple custom validation annotations and constraint validators. And this will involve doing an if else on constraintViolation.getPropertyPath() and then get the value from Foo object based on it. I was trying to find a way to avoid this manual process.Politico
What do you need the value for (how are you using it)? If you need it for embedding in the violation message, you can simply use context.unwrap(HibernateConstraintValidatorContext.class).addExpressionVariable("status", value.getStatus()) and refer to it in the validation message using ${status}Renault
As far as the 'maintenance nightmare' you refer to is concerned, you can use Lombok's @FieldNameConstantsRenault
Thanks for the comment @crizzis, I need the attribute name and value to be sent in the json error response in separate keys.Politico
The getInvalidValue() would also be helpful to assert it in tests when writing custom validation logic.Shayla
M
3

You can use dynamic payload to provide additional data in the constraint violation. It can be set using HibernateConstraintValidatorContext:

context.unwrap(HibernateConstraintValidatorContext.class)
        .withDynamicPayload(foo.getStatus().toString());

And javax.validation.ConstraintViolation can, in turn, be unwrapped to HibernateConstraintViolation in order to retrieve the dynamic payload:

constraintViolation.unwrap(HibernateConstraintViolation.class)
        .getDynamicPayload(String.class);

In the example above, we pass a simple string, but you can pass an object containing all the properties you need.


Note that this will only work with Hibernate Validator, which is the most widely used implementation of the Bean Validation specification (JSR-303/JSR-349), and used by Spring as its default validation provider.

Marcimarcia answered 22/7, 2020 at 10:24 Comment(7)
Great answer. Good to knowRunabout
Thanks for answer @anar . This way I can get the invalid value, how will I get the invalid attribute name?Politico
@Politico but you already do it in your code using getPropertyPath(), don't you? I also want to note that in a dynamic payload, you can pass not only strings, but, for example, an object, which in your case can contain the attribute name and value.Marcimarcia
Yes correct @Anar Currently, I set the invalid property like this context.getDefaultConstraintMessageTemplate()).addPropertyNode("status") which again involves maintaining string constant of java bean attribute. I want to avoid doing this.Politico
@Politico I don't think this is possible as you are using a class-level constraint that knows nothing about which field you validate.Marcimarcia
Yes correct. That's why I am looking for a cleaner approach (not necessarily custom validation and constraint validator) of doing such validations. I will try your suggestion, it will at least allow me remove if else checks for property to read invalid value.Politico
There is no "cleaner" approach. You either validate the field separately, or you validate the entire object, and then you yourself have to specify what exactly is wrong with one or more fields of your object. I don't quite understand what is the problem with using a string name when you use it in the same method where you are doing validation and you know which field you are actually validating. Of course, you can also get the property name using reflection, but that would be just silly in my opinion. Good luck anyway!Marcimarcia
M
2

You can use the expression language to evaluate the property path. E.g.

Set<ConstraintViolation<Foo>> constraintViolations = validator.validate(foo);
    constraintViolations.forEach(constraintViolation -> {
        Path propertyPath = constraintViolation.getPropertyPath();
        Foo rootBean = constraintViolation.getRootBean();

        Object invalidPropertyValue = getPropertyValue(rootBean, propertyPath);
        System.out.println(MessageFormat.format("{0} = {1}", propertyPath, invalidPropertyValue));
    });

private static Object getPropertyValue(Object bean, Path propertyPath) {
    ELProcessor el = new ELProcessor();
    el.defineBean("bean", bean);
    String propertyExpression = MessageFormat.format("bean.{0}", propertyPath);
    Object propertyValue = el.eval(propertyExpression);
    return propertyValue;
}

The expression language does also work with nested beans. Here is a full example

You will need Java >1.8 and the follwing dependencies:

<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>javax.el</artifactId>
    <version>3.0.0</version>
</dependency>
<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.0.Final</version>
</dependency>
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.2.Final</version>
</dependency>
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator-annotation-processor</artifactId>
    <version>6.0.2.Final</version>
</dependency>

and my java code

public class Main {

    public static void main(String[] args) {
        ValidatorFactory buildDefaultValidatorFactory = Validation.buildDefaultValidatorFactory();

        Validator validator = buildDefaultValidatorFactory.getValidator();

        // I added Bar to show how nested bean property validation works
        Bar bar = new Bar();

        // Must be 2 - 4 characters
        bar.setName("A");

        Foo foo = new Foo();
        foo.setBar(bar);
        foo.setCompletionDate(LocalDate.now());

        // must be complete or closed
        foo.setStatus("test");

        Set<ConstraintViolation<Foo>> constraintViolations = validator.validate(foo);

        System.out.println("Invalid Properties:");

        constraintViolations.forEach(constraintViolation -> {
            Path propertyPath = constraintViolation.getPropertyPath();
            Foo rootBean = constraintViolation.getRootBean();

            Object invalidPropertyValue = getPropertyValue(rootBean, propertyPath);
            System.out.println(MessageFormat.format("{0} = {1}", propertyPath, invalidPropertyValue));
        });
    }

    private static Object getPropertyValue(Object bean, Path propertyPath) {
        ELProcessor el = new ELProcessor();
        el.defineBean("bean", bean);
        String propertyExpression = MessageFormat.format("bean.{0}", propertyPath);
        Object propertyValue = el.eval(propertyExpression);
        return propertyValue;
    }

    @StatusValidate
    public static class Foo {
        private String status;
        private LocalDate completionDate;

        @Valid
        private Bar bar;

        public void setBar(Bar bar) {
            this.bar = bar;
        }

        public Bar getBar() {
            return bar;
        }

        public String getStatus() {
            return status;
        }

        public void setStatus(String status) {
            this.status = status;
        }

        public LocalDate getCompletionDate() {
            return completionDate;
        }

        public void setCompletionDate(LocalDate completionDate) {
            this.completionDate = completionDate;
        }

    }

    public static class Bar {

        @Size(min = 2, max = 4)
        private String status;

        public String getStatus() {
            return status;
        }

        public void setName(String status) {
            this.status = status;
        }
    }

    @Constraint(validatedBy = StatusValidator.class)
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    public static @interface StatusValidate {

        String message()

        default "default status error";

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

        Class<? extends Payload>[] payload() default {};
    }

    public static class StatusValidator implements ConstraintValidator<StatusValidate, Foo> {

        @Override
        public boolean isValid(Foo value, ConstraintValidatorContext context) {
            if (null != value.getCompletionDate() && (!value.getStatus().equalsIgnoreCase("complete")
                    && !value.getStatus().equalsIgnoreCase("closed"))) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                        .addPropertyNode("status").addConstraintViolation();
                return false;
            }
            return true;
        }
    }
}

Output is:

Invalid Properties:
status = test
bar.status = A
Marra answered 29/7, 2020 at 6:14 Comment(1)
Thanks for the answer. This solves the problem partially, but stil involves maintaining the bean attribute name "status" in context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate()).addPropertyNode("status").addConstraintViolation();Politico
C
0

Use @NotNull for Completion date and use Custom enum validator for status like this :

  /*enum class*/
public enum Status{
    COMPLETE,
    CLOSED
}

/*custom validator*/
@ValueValidator(EnumValidatorClass = Status.class)
@NotNull
private String status;

@NotNull
private LocalDate completionDate;

 /*anotation interface*/
@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValueValidator.class)
@Documented
public @interface ValueValidator {
    
       public abstract String message() default "Invalid Status!";
     
        public abstract Class<?>[] groups() default {};
      
        public abstract Class<? extends Payload>[] payload() default {};
         
        public abstract Class<? extends java.lang.Enum<?>> EnumValidatorClass();
         

}

/*anotation implementation*/
public class EnumValueValidator implements ConstraintValidator<ValueValidator, String>{

 private List<String> values;
 
@Override
public void initialize(ValueValidator annotation)
{
    values = Stream.of(annotation.EnumValidatorClass().getEnumConstants()).map(Enum::name).collect(Collectors.toList());
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
    if (value == null) {
        return true;
    }

    return values.contains(value);
}}
Chau answered 28/7, 2020 at 19:41 Comment(1)
Question is for combination of validations - status should be complete or closed only when completionDate is not null.Politico

© 2022 - 2024 — McMap. All rights reserved.