Search code examples
springspring-mvcspring-data-resthibernate-validatorspring-validator

Empty messages with Validation errors in Spring Data REST


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?


Solution

  • 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.