4

I'm creating an application using Spring Boot, Spring Data REST, Spring HATEOAS, Hibernate, Spring Validation.

I created my own validation to support SpEL following this guide.

So I've my Validator:

  public class SpELClassValidator implements ConstraintValidator<ValidateClassExpression, Object> {
    private Logger log = LogManager.getLogger();

    private ValidateClassExpression annotation;
    private ExpressionParser parser = new SpelExpressionParser();

    public void initialize(ValidateClassExpression constraintAnnotation) {
        annotation = constraintAnnotation;
        parser.parseExpression(constraintAnnotation.value());
    }

    public boolean isValid(Object value, ConstraintValidatorContext context) {
        try {           
            StandardEvaluationContext spelContext = new StandardEvaluationContext(value);
            return (Boolean) parser.parseExpression(annotation.value()).getValue(spelContext);
        } catch (Exception e) {
            log.error("", e);
            return false;
        }

    }
}

and my annotation:

@Target({ java.lang.annotation.ElementType.TYPE, java.lang.annotation.ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = { SpELClassValidator.class })
@Documented
@Repeatable(ValidateClassExpressions.class)
public @interface ValidateClassExpression {

    String message() default "{expression.validation.message}";

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

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

    String value();

}

Configuration of validator:

@Bean
public MessageSource messageSource() {
    ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
    messageSource.setBasenames("classpath:/i18n/messages");
    // messageSource.setDefaultEncoding("UTF-8");
    // set to true only for debugging
    messageSource.setUseCodeAsDefaultMessage(false);
    messageSource.setCacheSeconds((int) TimeUnit.HOURS.toSeconds(1));
    messageSource.setFallbackToSystemLocale(false);
    return messageSource;
}

/**
 * Enable Spring bean validation
 * https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#validation
 * 
 * @return
 */
@Bean   
public LocalValidatorFactoryBean validator() {
    LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
    factoryBean.setValidationMessageSource(messageSource());
    return factoryBean;
}

@Bean
public MethodValidationPostProcessor methodValidationPostProcessor() {
    MethodValidationPostProcessor methodValidationPostProcessor = new MethodValidationPostProcessor();
    methodValidationPostProcessor.setValidator(validator());
    return methodValidationPostProcessor;
}

..and defined validator for REST repositories:

    @Configuration
public class RestConfig extends RepositoryRestConfigurerAdapter {
    @Autowired
    private Validator validator;

    public static final DateTimeFormatter ISO_FIXED_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
            .withZone(ZoneId.of("Z"));

    @Bean
    public RootResourceProcessor rootResourceProcessor() {
        return new RootResourceProcessor();
    }

    @Override
    public void configureExceptionHandlerExceptionResolver(ExceptionHandlerExceptionResolver exceptionResolver) {

    }

    @Override
    public void configureValidatingRepositoryEventListener(ValidatingRepositoryEventListener validatingListener) {
        validatingListener.addValidator("beforeCreate", validator);
        validatingListener.addValidator("beforeSave", validator);
        super.configureValidatingRepositoryEventListener(validatingListener);
    }
}

this is my bean:

    @Entity
// Validate the number of seats if the bus is a minibus
@ValidateClassExpression(value = "#this.isMiniBus() == true ? #this.getSeats()<=17 : true", message = "{Expression.licenseplate.validminibus}")
public class LicensePlate extends AbstractEntity {
    private static final long serialVersionUID = -6871697166535810224L;

    @NotEmpty
    @ColumnTransformer(read = "UPPER(licensePlate)", write = "UPPER(?)")
    @Column(nullable = false, unique = true)
    private String licensePlate;

    // The engine euro level (3,4,5,6)
    @Range(min = 0, max = 6)
    @NotNull
    @Column(nullable = false, columnDefinition = "INTEGER default 0")
    private int engineEuroLevel = 0;

    @NotNull(message = "{NotNull.licenseplate.enginetype}")
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private EngineType engineType = EngineType.DIESEL;

    // If the bus has the particulate filter
    @NotNull(message = "{NotNull.licenseplate.particulatefilter}")
    @Column(nullable = false, columnDefinition = "BOOLEAN default false")
    private boolean particulateFilter = false;

    // Number of seats
    @NotNull
    @Range(min = 1, max = 99)
    @Column(nullable = false, columnDefinition = "INTEGER default 50")
    private int seats = 50;

    // If the vehicle is a minibus
    @NotNull
    @Column(nullable = false, columnDefinition = "BOOLEAN default false")
    private boolean miniBus = false;

    @NotNull(message = "{NotNull.licenseplate.country}")
    // The country of the vehicle
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Country country;

    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Note> notes = new ArrayList<>();

    public LicensePlate() {
    }

    public String getLicensePlate() {
        return licensePlate;
    }

    public void setLicensePlate(String licensePlate) {
        this.licensePlate = licensePlate;
    }

    public int getEngineEuroLevel() {
        return engineEuroLevel;
    }

    public void setEngineEuroLevel(int engineEuroLevel) {
        this.engineEuroLevel = engineEuroLevel;
    }

    public int getSeats() {
        return seats;
    }

    public void setSeats(int seats) {
        this.seats = seats;
    }

    public boolean isMiniBus() {
        return miniBus;
    }

    public void setMiniBus(boolean miniBus) {
        this.miniBus = miniBus;
    }

    public EngineType getEngineType() {
        return engineType;
    }

    public void setEngineType(EngineType engineType) {
        this.engineType = engineType;
    }

    public boolean isParticulateFilter() {
        return particulateFilter;
    }

    public void setParticulateFilter(boolean particulateFilter) {
        this.particulateFilter = particulateFilter;
    }

    public Country getCountry() {
        return country;
    }

    public void setCountry(Country country) {
        this.country = country;
    }

    @Override
    public String toString() {
        return "LicensePlate [licensePlate=" + licensePlate + ", engineEuroLevel=" + engineEuroLevel + ", engineType="
                + engineType + ", particulateFilter=" + particulateFilter + ", seats=" + seats + ", miniBus=" + miniBus
                + "]";
    }

    public List<Note> getNotes() {
        return notes;
    }

    public void setNotes(List<Note> notes) {
        this.notes = notes;
    }

}

On configuration I've also this class:

@RestControllerAdvice
public class ApplicationExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
            HttpHeaders headers, HttpStatus status, WebRequest request) {
        throw new RuntimeException(ex);
    }

    @Override
    protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex,
            HttpHeaders headers, HttpStatus status, WebRequest request) {
        throw new RuntimeException(ex);
    }

    @Override
    protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex,
            HttpHeaders headers, HttpStatus status, WebRequest request) {
        throw new RuntimeException(ex);
    }

}

Using my repository:

@Transactional
@RepositoryRestResource(excerptProjection = LicensePlateProjection.class)
@PreAuthorize("isAuthenticated()")
public interface LicensePlateRepository
        extends PagingAndSortingRepository<LicensePlate, Long>, RevisionRepository<LicensePlate, Long, Integer> {

    public LicensePlate findByLicensePlate(String licencePlate);

Using Swagger I'm doing a POST of this json:

{"licensePlate":"asdfg","engineEuroLevel":"4","particulateFilter":true,"seats":18,"miniBus":true,"country":"http://localhost:8080/api/v1/countries/1"}

Because I've the validation rule that check a minibus has less than 17 seats, I should see a validation error, instad I see this:

 {
  "errors": []
}

with an HTTP 400 error (this return code is right).

I've to point out that I created Junit test cases and I see the right message:

@Test
@WithMockUser(roles = "ADMIN")
public void validateMinibusWithMoreThan17SeatsFails() {
    assertEquals(1, countryRepository.count());

    LicensePlate plate = new LicensePlate();
    plate.setLicensePlate("AA123BB");
    plate.setEngineEuroLevel(3);
    plate.setMiniBus(true);
    plate.setSeats(18);
    plate.setCountry(countryRepository.findFirstByOrderByIdAsc());

    Set<ConstraintViolation<LicensePlate>> constraintViolations = validator.validate(plate);
    assertEquals(1, constraintViolations.size());
    ConstraintViolation<LicensePlate> constraintViolation = constraintViolations.iterator().next();
    assertEquals("I veicoli di tipo minibus possono avere al massimo 17 posti (16 passeggeri più il conducente).",
            constraintViolation.getMessage());
}

So I guess the problem is on the REST/MVC part. I debugged the request and I checked the class org.springframework.data.rest.core.RepositoryConstraintViolationException; in the constructor i see my errors are right and I can see the error message and the right structure:

org.springframework.data.rest.core.ValidationErrors: 1 errors
Error in object 'LicensePlate': codes [ValidateClassExpression.LicensePlate,ValidateClassExpression]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [LicensePlate.,]; arguments []; default message [],org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@520b6a25]; default message [I veicoli di tipo minibus possono avere al massimo 17 posti (16 passeggeri più il conducente).]

I can't see where I am making the mistake. With other (also) custom validators I see the right message. I someone also to put me in the right direction to solve the problem?

7
  • Hi! ) Did you register your validator? Do not you want to see my SDR validation example?.. Commented Oct 10, 2017 at 18:00
  • @Cepr0 Yes I did. I updated my question. I checked your example and seems I'm doing the same thing. Commented Oct 10, 2017 at 18:08
  • As I can see you've registered only standard validator (@Autowired private Validator validator;) but not custom one: validatingListener.addValidator("beforeSave", new SpELClassValidator());. Or I'm mistaken?.. Commented Oct 10, 2017 at 18:15
  • Uhm, I don't think I have to do that. My validator (javax.validation.ConstraintValidator) is used from my specific annotation ValidateClassExpression. I can't set in the validatingListener because has a different type. Furthermore I've others custom validators as this and they works fine (I mean the return message). Commented Oct 10, 2017 at 19:26
  • Does it work with other custom class-level constraints, e.g. Hibernate Validator's @ScriptAssert? Commented Oct 14, 2017 at 21:36

1 Answer 1

2

I reckon that Spring MVC doesn't know where to show the error message as the constraint violation of the class-level constraint doesn't indicate any specific property.

HV's @ScriptAssert provides the reportOn() attribute for specifying a property to report the error on.

For your custom constraint you could do the same by creating a customized constraint violation and property path using the API exposed via ConstraintValidatorContext.

Sign up to request clarification or add additional context in comments.

1 Comment

You are right. I guessed also without a specific property Spring mvc was able to display CoinstraintValidation errors. BTW I had to update Hibernate Validator to version >= 5.4 because reportOn() is available only from that release.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.