Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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.
like image 755
Smile Avatar asked Jul 19 '20 13:07

Smile


2 Answers

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.

like image 178
Anar Sultanov Avatar answered Sep 21 '22 18:09

Anar Sultanov


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
like image 37
René Link Avatar answered Sep 23 '22 18:09

René Link