Search code examples
spring-bootbean-validation

Bean validator: validate nested object while adding a prefix to its error messages


I'm having a problem where, when I have multiple nested beans of the same type, the returned message may end up being the same, which can confuse the user:

Minimal example (the real beans have lots of fields):

class A {
    @NotBlank("Name is obligatory.")
    String name;
    
    @NotBlank("Address is obligatory.")
    String name;
}

class B {
    @Valid
    A origin;

    @Valid
    A destination;
}

If I run B against the validator with blank names, it will always return "Name is obligatory.", no matter if it comes from the origin or from the destination. I know that the error message comes with the field names, but that information, by itself, is not very useful for the end user.

Is there some annotation that validates the nested beans similarly to what @Valid does, but adding a prefix, so that instead of saying "Name is obligatory.", it would say either "Original person: Name is obligatory." or "Destination person: Name is obligatory."?


Solution

  • I couldn't find any out-of-the box way to implement what was needed, so I had to implement a custom constraint:

    PrefixConstraint.java

    import java.lang.annotation.Documented;
    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    
    import javax.validation.Constraint;
    import javax.validation.Payload;
    
    @Documented
    @Constraint(validatedBy = PrefixConstraintValidator.class)
    @Target( { ElementType.METHOD, ElementType.FIELD })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface PrefixConstraint {
    
        String message() default "Prefix missing";
        Class<?>[] groups() default {};
        Class<? extends Payload>[] payload() default {};    
    }
    

    PrefixConstraintValidator.java

    import java.util.Set;
    
    import javax.validation.ConstraintValidator;
    import javax.validation.ConstraintValidatorContext;
    import javax.validation.ConstraintViolation;
    import javax.validation.Validation;
    import javax.validation.Validator;
    
    public class PrefixConstraintValidator implements ConstraintValidator<PrefixConstraint, Object> {
        
        private PrefixConstraint constraint;
    
        @Override
        public void initialize(PrefixConstraint constraintAnnotation) {
            this.constraint = constraintAnnotation;
        }
    
        @Override
        public boolean isValid(Object value, ConstraintValidatorContext context) {
            if (value == null) {
                return true;
            }       
    
            Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
            Set<ConstraintViolation<Object>> violations = validator.validate(value, this.constraint.groups());
            context.disableDefaultConstraintViolation();
            for (ConstraintViolation<Object> violation : violations) {
                context
                    .buildConstraintViolationWithTemplate(this.constraint.message() + ": " + violation.getMessage())
                    .addPropertyNode(violation.getPropertyPath().toString())
                    .addConstraintViolation();          
            }
            return violations.isEmpty();
        }
    
    }
    

    Usage example:

    class A {
        @NotBlank("Name is obligatory.")
        String name;
        
        @NotBlank("Address is obligatory.")
        String name;
    }
    
    class B {
        @PrefixConstraint(message = "Original person")
        A origin;
    
        @PrefixConstraint(message = "Destination person")
        A destination;
    }
    

    Now, if the names are left blank, the validator will return the message: "Original person: Name is obligatory" and "Destination person: Name is obligatory".