Erik Volkman
Erik Volkman

Reputation: 41

How to specify different locales for each bean validation call

I'm working on a platform that runs Spring Batch jobs which are responsible for retrieving a group of objects from a third party application, performs bean validations and returns any constraint violations back up to the third party application for the user to then correct (items without violations get transformed and passed to another application). Right now, we use the Validator configured by Spring Boot and this all works great in English.

We're expanding which users have access to the third party application and now need to provide the constraint validations in a language appropriate to the user who created the object. I have a means to lookup the language/locale needed for a particular object, but what I'm missing is how to tell the Validator the locale of the messages in the Set<ConstraintViolation<T>> returned by the validate(<T> object) method. Furthermore, there might be multiple jobs running at the same time, each validating their own type of object and needing the violations reported back in a different language. Ideally, it would be nice to have a validate(<T> object, Locale locale) method, but that doesn't exist in the Validator interface.

My first thought was to write a custom MessageInterpolator, and set the appropriate Locale prior to each validation (see ValueMessageInterpolator and DemoJobConfig below) however it's not thread-safe, so it's possible we could end up with with messages in the wrong language.

I also considered if there was a way to use the LocaleResolver interface to assist instead, but I'm not seeing a solution that wouldn't have the same issues as the MessageInterpolator.

Based on what I've determined so far, it seems like my only solutions are:

  1. Instantiate separate Validators and MessageInterpolators for each batch job/step that needs one and use the approach already presented. This approach seems rather inefficient because of the cycling through these objects.
  2. Create a service bean that contains a collection of Validators, one for each Locale needed. Each batch job/step could then reference this new service and the service would be responsible for delegating to the appropriate Validator. The validators could be setup something like this and would limit the number of validators needed to the number of languages we support.
javax.validation.Validator caFRValidator = Validation.byProvider(HibernateValidator.class).configure().localeResolver(context -> {return Locale.CANADA_FRENCH;}).buildValidatorFactory().getValidator();
javax.validation.Validator usValidator = Validation.byProvider(HibernateValidator.class).configure().localeResolver(context -> {return Locale.US;}).buildValidatorFactory().getValidator();
javax.validation.Validator germanValidator = Validation.byProvider(HibernateValidator.class).configure().localeResolver(context -> {return Locale.GERMANY;}).buildValidatorFactory().getValidator();
  1. Instead of calling a Validator directly, create a microservice that would just accept objects for validation and then pass in the requisite Locale via the Accept-Language header. While I might get away with only having one Validator bean, this seems unnecessarily complex.

Are there alternative approaches that could be used to solve this problem?

We are currently using the 2.5.3 spring-boot-starter-parent pom to manage dependencies and would likely update to the most recent 2.6.x release by the time we need to implement these changes.

ValueMessageInterpolator.java

public class ValueMessageInterpolator implements MessageInterpolator {

    private final MessageInterpolator interpolator;
    private Locale currentLocale;
    
    public ValueMessageInterpolator(MessageInterpolator interp) {
        this.interpolator = interp;
        this.currentLocale = Locale.getDefault();
    }
    
    public void setLocale(Locale locale) {
        this.currentLocale = locale;
    }
    
    @Override
    public String interpolate(String messageTemplate, Context context) {
        return interpolator.interpolate(messageTemplate, context, currentLocale);
    }

    @Override
    public String interpolate(String messageTemplate, Context context, Locale locale) {
        return interpolator.interpolate(messageTemplate, context, locale);
    }

}

ToBeValidated.java

public class ToBeValidated {
    @NotBlank
    private final String value;

    private final Locale locale;
    
    // Other boilerplate code removed
}

DemoJobConfig.java

@Configuration
@EnableBatchProcessing
public class DemoJobConfig extends DefaultBatchConfigurer {

    @Bean
    public ValueMessageInterpolator buildInterpolator() {
        return new ValueMessageInterpolator(Validation.byDefaultProvider().configure().getDefaultMessageInterpolator());
    }

    @Bean
    public javax.validation.Validator buildValidator(ValueMessageInterpolator valueInterp) {
        return Validation.byDefaultProvider().configure().messageInterpolator(valueInterp).buildValidatorFactory().getValidator();
    }

    @Bean
    public Job configureJob(JobBuilderFactory jobFactory, Step demoStep) {
        return jobFactory.get("demoJob").start(demoStep).build();
    }

    @Bean
    public Step configureStep(StepBuilderFactory stepFactory, javax.validation.Validator constValidator, ValueMessageInterpolator interpolator) {

        ItemReader<ToBeValidated> reader = 
                new ListItemReader<ToBeValidated>(Arrays.asList(
                        new ToBeValidated("values1", Locale.US),            // (No errors)
                        new ToBeValidated("", Locale.US),                   // value: must not be blank
                        new ToBeValidated("", Locale.CANADA),               // value: must not be blank
                        new ToBeValidated("value3", Locale.CANADA_FRENCH),  // (No errors)  
                        new ToBeValidated("", Locale.FRANCE),               // value: ne doit pas être vide
                        new ToBeValidated("", Locale.GERMANY)               // value: kann nicht leer sein
                        ));

        Validator<ToBeValidated> springValidator = new Validator<ToBeValidated>() {
            @Override
            public void validate(ToBeValidated value) throws ValidationException {
                interpolator.setLocale(value.getLocale());
                String errors = constValidator.validate(value).stream().map(v -> v.getPropertyPath().toString() +": "+v.getMessage()).collect(Collectors.joining(","));
                if(errors != null && !errors.isEmpty()) {
                    throw new ValidationException(errors);
                }
            }
        };

        ItemProcessor<ToBeValidated, ToBeValidated> processor = new ValidatingItemProcessor<ToBeValidated>(springValidator);

        ItemWriter<ToBeValidated> writer =  new ItemWriter<ToBeValidated>() {
            @Override
            public void write(List<? extends ToBeValidated> items) throws Exception {
                items.forEach(System.out::println);
            }
        };
        
        SkipListener<ToBeValidated, ToBeValidated> skipListener = new SkipListener<ToBeValidated, ToBeValidated>() {
            @Override
            public void onSkipInRead(Throwable t) {}

            @Override
            public void onSkipInWrite(ToBeValidated item, Throwable t) {}

            @Override
            public void onSkipInProcess(ToBeValidated item, Throwable t) {
                System.out.println("Skipped ["+item.toString()+"] for reason(s) ["+t.getMessage()+"]");
            }
        };

        return stepFactory.get("demoStep")
                .<ToBeValidated, ToBeValidated>chunk(2)
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(10)
                .listener(skipListener)
                .build();
    }

    @Override
    public PlatformTransactionManager getTransactionManager() {
        return new ResourcelessTransactionManager();
    }
}

Upvotes: 2

Views: 1440

Answers (1)

Erik Volkman
Erik Volkman

Reputation: 41

The ValidationAutoConfiguration from Spring Boot creates a LocalValidatorFactoryBean, where, in the afterPropertiesSet() method a LocaleContextMessageInterpolator is configured.

So, the only change needed to support this requirement is a LocaleContextHolder.setLocale(Locale locale) added prior to the validation call in the ItemProcessor. The LocalContextHolder keeps a ThreadLocal<LocaleContext> which allows each thread (job/step) to keep it's own version of the current Locale being used.

        Validator<ToBeValidated> springValidator = new Validator<ToBeValidated>() {
            @Override
            public void validate(ToBeValidated value) throws ValidationException {
                LocaleContextHolder.setLocale(value.getLocale());
                String errors = constValidator.validate(value).stream().map(v -> v.getPropertyPath().toString() +": "+v.getMessage()).collect(Collectors.joining(","));
                if(errors != null && !errors.isEmpty()) {
                    throw new ValidationException(errors);
                }
            }
        };

Upvotes: 1

Related Questions