codeboy
codeboy

Reputation: 43

Spring Hibernate validation - getting one error message per field

I'm stuggling with getting only one error message per field. I have a lot of rules for each field and I want to validate them one by one. If one fails, validation stops and returns only one message describing failed rule for this field.

After research I've found something like @ReportAsSingleViolation annotation and it kinda works, but it have fixed message from custom constraint. So it's not what I want.

I've read about @GroupSequence but I can't get it working like I've described either.

This is my entity with custom constraint rules:

@Entity
@Table(name = "users", schema = "myschema")
public class User {
    private int id;

    @ValidLogin
    private String login;

    @ValidPassword
    private String password;

    @ValidEmail
    private String email;

    //getters & setters
}

And implementation of my custom constraint with couple built-in rules:

@Constraint(validatedBy = UsernameValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@NotEmpty
@Pattern(regexp = "^[a-zA-Z0-9]*$")
@Length.List({
        @Length(min = 3 , message = "{Length.min.user.login}"),
        @Length(max = 30, message = "{Length.max.user.login}")
})
public @interface ValidLogin {

    String message() default "";

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

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

And by default I get every message for failed rule in my jsp view. So again, I want to get it working like this: check for rule @NotEmpty and if it fails, return appropriate message, if not validate next rule @Pattern and so on.

Could you help? Thanks a lot!

Upvotes: 2

Views: 6752

Answers (3)

Slava Semushin
Slava Semushin

Reputation: 15204

Each annotation has groups attribute that could be used for dividing checks to groups:

public class MyForm {

    @NotEmpty(groups = LoginLevel1.class)
    @Pattern(regexp = "^[a-zA-Z0-9]*$", groups = LoginLevel2.class)
    @Length.List({
        @Length(min = 3 , message = "{Length.min.user.login}", groups = LoginLevel3.class),
        @Length(max = 30, message = "{Length.max.user.login}", groups = LoginLevel3.class)
    })
    private String login;
}

Next step is to group these groups with @GroupSequence that allows to have fail-fast behavior:

public class MyForm {
    ...
    @GroupSequence({
        LoginLevel1.class,
        LoginLevel2.class,
        LoginLevel3.class
    })
    public interface LoginChecks {}

    public interface LoginLevel1 {}
    public interface LoginLevel2 {}
    public interface LoginLevel3 {}
}

The final step is to instruct Spring to validate using these group sequences:

@PostMapping("/form-handler")
public String processForm(@Validated({MyForm.LoginChecks.class, MyForm.PasswordChecks.class}) MyForm form, BindingResult result) {

    if (result.hasErrors()) {
        return null;
    }
    ...
}

Upvotes: 0

mark_o
mark_o

Reputation: 2508

Here's what, I think, you are looking for:

@Test
public void test() {
    Validator v = Validation.byProvider( HibernateValidator.class )
            .configure()
            .buildValidatorFactory()
            .getValidator();

    // validations for each group - shows only corresponding violations even if other constraints
    // are violated as well
    assertThat( v.validate( new Bar( null, null ), First.class ) ).hasSize( 2 );
    assertThat( v.validate( new Bar( "", "" ), Second.class ) ).hasSize( 2 );
    assertThat( v.validate( new Bar( "a", "a" ), Third.class ) ).hasSize( 2 );

    // shows that validation will go group by group as defined in the sequence:
    //NotNull
    Set<ConstraintViolation<Bar>> violations = v.validate( new Bar( null, null ) );
    assertThat( violations ).hasSize( 2 );
    assertThat( violations ).extracting( "message" ).containsOnly( "must not be null" );

    //NotBlank
    violations = v.validate( new Bar( "", "" ) );
    assertThat( violations ).hasSize( 2 );
    assertThat( violations ).extracting( "message" ).containsOnly( "must not be blank" );

    //Size
    violations = v.validate( new Bar( "a", "a" ) );
    assertThat( violations ).hasSize( 2 );
    assertThat( violations ).extracting( "message" ).containsOnly( "size must be between 5 and 2147483647" );

}

@GroupSequence({ First.class, Second.class, Third.class, Bar.class })
private static class Bar {

    @NotNull(groups = First.class)
    @NotBlank(groups = Second.class)
    @Size(min = 5, groups = Third.class)
    private final String login;

    @NotNull(groups = First.class)
    @NotBlank(groups = Second.class)
    @Size(min = 5, groups = Third.class)
    private final String password;

    public Bar(String login, String password) {
        this.login = login;
        this.password = password;
    }
}

interface First {
}

interface Second {
}

interface Third {
}

I've added a test so it is visible how validation goes group by group. And to have such behavior you need to redefine a default group sequence for your bean. To do that you need to place @GroupSequence annotation on your bean that you'd like to validate, then list all the groups you need and don't forget to add the bean class itself (like in this example). Also all of this information is present here - in the documentation.


Edit

If you are OK with not using standard constraints you then might do something like:

    @Test
public void test2() throws Exception {
    Set<ConstraintViolation<Foo>> violations = validator.validate( new Foo( "", null ) );
    assertThat( violations ).hasSize( 2 );
    assertThat( violations ).extracting( "message" )
            .containsOnly( "value should be between 3 and 30 chars long", "Value cannot be null" );
}

private static class Foo {

    @ValidLogin
    private final String login;

    @ValidLogin
    private final String password;

    public Foo(String login, String password) {
        this.login = login;
        this.password = password;
    }
}

@Target({ FIELD })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = { ValidLogin.ValidLoginValidator.class })
@interface ValidLogin {
    String message() default "message";

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

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

    class ValidLoginValidator implements ConstraintValidator<ValidLogin, String> {
        private static final Pattern PATTERN = Pattern.compile( "^[a-zA-Z0-9]*$" );

        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            String message = "";
            if ( value == null ) {
                message = "Value cannot be null";
            }
            else if ( !PATTERN.matcher( value ).matches() ) {
                message = "Value should match pattern ";
            }
            else if ( message.length() < 3 || message.length() > 30 ) {
                message = "value should be between 3 and 30 chars long";
            }
            if ( !message.isEmpty() ) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate( message ).addConstraintViolation();
            }
            return false;
        }
    }
}

In this case you just have your own custom constraint and validator for it. And you go check by check and then build the violation based on the first failed check. Also you could extract things like pattern and min, max as attributes to your constraint if you have similar checks to perform for login and password but for example with different patterns on string length...

Upvotes: 3

codeboy
codeboy

Reputation: 43

I was wondering if GroupSequence didnt't work because of custom constraint and YES, that's why.

I was aplying built-in rules in custom constraint and there, groups for them don't work. In custom constraint was visible only one group DEFAULT, because of:

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

When I moved those built-in rules from custom constraint to field (which is more ugly now, I wanted to keep Entity pretty) this works.

But here we go again. Now it must go "level" by "level" meaning when one field is blank, but the others not, there be only one message for empty one. Others even thogh they are invalid, are waiting for next "level" sequence. Which is again - NOT WHAT I WANT.

Seems like getting one error per field is too much for spring / hibernate.

If anyone hava an idea how to get it working, please, let me know, I'll give it a try.

Upvotes: 0

Related Questions