#1019 - Improvements to I18N optimizations.

We now properly look for resource bundles by scanning for a resource bundle *pattern* instead of a plain file named rest-messages. Added integration tests to make sure that resource bundles are not skipped even if they existed.

Added the ability to define common messages in a rest-default-messages.properties, as Spring Data REST requires that and it would be too cumbersome for Spring Data REST to additionally deploy them in its own configuration.
This commit is contained in:
Oliver Drotbohm
2019-07-31 18:11:26 +02:00
parent b1af903de0
commit 400a6bb703
14 changed files with 165 additions and 30 deletions

View File

@@ -15,14 +15,26 @@
*/
package org.springframework.hateoas.config;
import org.springframework.context.MessageSource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.PropertiesFactoryBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.context.support.AbstractMessageSource;
import org.springframework.context.support.AbstractResourceBasedMessageSource;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.hateoas.client.LinkDiscoverer;
import org.springframework.hateoas.client.LinkDiscoverers;
import org.springframework.hateoas.mediatype.MessageResolver;
@@ -50,7 +62,9 @@ import org.springframework.util.ClassUtils;
@Configuration
@Import(EntityLinksConfiguration.class)
@EnablePluginRegistries({ LinkDiscoverer.class })
class HateoasConfiguration {
public class HateoasConfiguration {
private @Autowired ApplicationContext context;
@Bean
public MessageResolver messageResolver() {
@@ -104,17 +118,53 @@ class HateoasConfiguration {
* @return will never be {@literal null}.
*/
@Nullable
private static final MessageSource lookupMessageSource() {
private final AbstractMessageSource lookupMessageSource() {
ClassPathResource resource = new ClassPathResource("rest-messages");
List<Resource> candidates = loadProperties("rest-default-messages", false);
if (!resource.exists()) {
if (candidates.isEmpty() && loadProperties("rest-messages", true).isEmpty()) {
return null;
}
AbstractResourceBasedMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:rest-messages");
messageSource.setDefaultEncoding(StandardCharsets.UTF_8.toString());
if (!candidates.isEmpty()) {
messageSource.setCommonMessages(loadProperties(candidates));
}
return messageSource;
}
@Nullable
private final Properties loadProperties(Collection<Resource> sources) {
Resource[] resources = loadProperties("rest-default-messages", false).stream().toArray(Resource[]::new);
PropertiesFactoryBean factory = new PropertiesFactoryBean();
factory.setLocations(resources);
try {
factory.afterPropertiesSet();
return factory.getObject();
} catch (IOException o_O) {
throw new IllegalStateException("Could not load default properties from resources!", o_O);
}
}
private final List<Resource> loadProperties(String baseName, boolean withWildcard) {
try {
return Arrays //
.stream(context.getResources(String.format("classpath:%s%s.properties", baseName, withWildcard ? "*" : ""))) //
.filter(Resource::exists) //
.collect(Collectors.toList());
} catch (IOException e) {
return Collections.emptyList();
}
}
}

View File

@@ -18,7 +18,12 @@ package org.springframework.hateoas.mediatype;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.lang.Nullable;
enum NoMessageResolver implements MessageResolver {
/**
* {@link MessageResolver} to always resort to the {@link MessageSourceResolvable}'s default message.
*
* @author Oliver Drotbohm
*/
enum DefaultOnlyMessageResolver implements MessageResolver {
INSTANCE;
@@ -29,6 +34,6 @@ enum NoMessageResolver implements MessageResolver {
@Nullable
@Override
public String resolve(MessageSourceResolvable resolvable) {
return null;
return resolvable.getDefaultMessage();
}
}

View File

@@ -28,7 +28,7 @@ import org.springframework.lang.Nullable;
*/
public interface MessageResolver {
public static final MessageResolver NONE = NoMessageResolver.INSTANCE;
public static final MessageResolver DEFAULTS_ONLY = DefaultOnlyMessageResolver.INSTANCE;
/**
* Resolve the given {@link MessageSourceResolvable}. Return {@literal null} if no message was found.
@@ -46,6 +46,9 @@ public interface MessageResolver {
* @return will never be {@literal null}.
*/
public static MessageResolver of(@Nullable MessageSource messageSource) {
return messageSource == null ? NoMessageResolver.INSTANCE : new MessageSourceResolver(messageSource);
return messageSource == null //
? DefaultOnlyMessageResolver.INSTANCE //
: new MessageSourceResolver(messageSource);
}
}

View File

@@ -18,6 +18,7 @@ package org.springframework.hateoas.mediatype.hal;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.hateoas.client.LinkDiscoverer;
@@ -42,7 +43,7 @@ public class HalMediaTypeConfiguration implements HypermediaMappingInformation {
private final LinkRelationProvider relProvider;
private final ObjectProvider<CurieProvider> curieProvider;
private final ObjectProvider<HalConfiguration> halConfiguration;
private final MessageResolver resolver;
private final @Qualifier("messageResolver") MessageResolver resolver;
/**
* @param relProvider

View File

@@ -121,14 +121,19 @@ public class Jackson2HalModule extends SimpleModule {
}
public HalLinkListSerializer(@Nullable BeanProperty property, CurieProvider curieProvider, EmbeddedMapper mapper,
MessageResolver accessor, HalConfiguration halConfiguration) {
MessageResolver resolver, HalConfiguration halConfiguration) {
super(TypeFactory.defaultInstance().constructType(Links.class));
Assert.notNull(curieProvider, "CurieProvider must not be null!");
Assert.notNull(mapper, "EmbeddedMapper must not be null!");
Assert.notNull(resolver, "MessageResolver must not be null!");
Assert.notNull(halConfiguration, "HalConfiguration must not be null!");
this.property = property;
this.curieProvider = curieProvider;
this.mapper = mapper;
this.resolver = accessor;
this.resolver = resolver;
this.halConfiguration = halConfiguration;
}

View File

@@ -65,7 +65,7 @@ public class Server implements Closeable {
this.mapper = new ObjectMapper();
this.mapper.registerModule(new Jackson2HalModule());
this.mapper.setHandlerInstantiator(
new Jackson2HalModule.HalHandlerInstantiator(relProvider, CurieProvider.NONE, MessageResolver.NONE));
new Jackson2HalModule.HalHandlerInstantiator(relProvider, CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY));
initJadler() //
.withDefaultResponseContentType(MediaTypes.HAL_JSON.toString()) //

View File

@@ -16,9 +16,11 @@
package org.springframework.hateoas.config;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.springframework.hateoas.mediatype.hal.HalConfiguration.RenderSingleLinks.*;
import static org.springframework.hateoas.support.ContextTester.*;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Optional;
@@ -26,17 +28,21 @@ import java.util.function.Function;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.client.LinkDiscoverer;
import org.springframework.hateoas.client.LinkDiscoverers;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.mediatype.MessageResolver;
import org.springframework.hateoas.mediatype.collectionjson.CollectionJsonLinkDiscoverer;
import org.springframework.hateoas.mediatype.hal.HalConfiguration;
import org.springframework.hateoas.mediatype.hal.HalLinkDiscoverer;
@@ -54,6 +60,7 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConvert
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodArgumentResolverComposite;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@@ -522,6 +529,37 @@ class EnableHypermediaSupportIntegrationTest {
);
}
@Test // #1019
void registersNoOpMessageResolverIfMessagesBundleMissing() {
withServletContext(HateoasConfiguration.class, //
context -> {
assertThat(context.getBean(MessageResolver.class)).isEqualTo(MessageResolver.of(null));
});
}
@Test // #1019
void registersMessageResolverIfMessagesBundleAvailable() {
withServletContext(HateoasConfiguration.class, simulateResourceBundle(), context -> {
assertThat(context.getBean(MessageResolver.class)).isNotEqualTo(MessageResolver.of(null));
});
}
@Test // #1019, DATAREST-686
void defaultsEncodingOfMessageSourceToUtf8() throws Exception {
withServletContext(HalConfig.class, simulateResourceBundle(), context -> {
MessageResolver resolver = context.getBean(MessageResolver.class);
Object accessor = ReflectionTestUtils.getField(resolver, "accessor");
Object messageSource = ReflectionTestUtils.getField(accessor, "messageSource");
assertThat((String) ReflectionTestUtils.getField(messageSource, "defaultEncoding")).isEqualTo("UTF-8");
});
}
private static void assertEntityLinksSetUp(ApplicationContext context) {
assertThat(context.getBeansOfType(EntityLinks.class).values()) //
@@ -607,6 +645,29 @@ class EnableHypermediaSupportIntegrationTest {
throw new IllegalStateException("Unexpected result when looking up argument resolvers!");
}
private static <T extends AnnotationConfigWebApplicationContext> Function<T, T> simulateResourceBundle() {
return context -> {
T spy = Mockito.spy(context);
ClassPathResource resource = new ClassPathResource("rest-messages.properties",
EnableHypermediaSupportIntegrationTest.class);
assertThat(resource.exists()).isTrue();
try {
doReturn(new Resource[0]).when(spy).getResources("classpath:rest-default-messages.properties");
doReturn(new Resource[] { resource }).when(spy).getResources("classpath:rest-messages*.properties");
} catch (IOException o_O) {
fail("Couldn't mock resource lookup!", o_O);
}
return spy;
};
}
@Configuration
@EnableWebMvc
@Import(DelegateConfig.class)

View File

@@ -89,7 +89,7 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg
mapper.registerModule(new Jackson2HalModule());
mapper.setHandlerInstantiator(
new HalHandlerInstantiator(provider, CurieProvider.NONE, MessageResolver.NONE, new HalConfiguration()));
new HalHandlerInstantiator(provider, CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY, new HalConfiguration()));
}
/**
@@ -447,7 +447,7 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg
void rendersSingleLinkAsArrayWhenConfigured() throws Exception {
mapper.setHandlerInstantiator(new HalHandlerInstantiator(new AnnotationLinkRelationProvider(), CurieProvider.NONE,
MessageResolver.NONE, new HalConfiguration().withRenderSingleLinks(RenderSingleLinks.AS_ARRAY)));
MessageResolver.DEFAULTS_ONLY, new HalConfiguration().withRenderSingleLinks(RenderSingleLinks.AS_ARRAY)));
RepresentationModel<?> resourceSupport = new RepresentationModel<>();
resourceSupport.add(new Link("localhost").withSelfRel());
@@ -480,7 +480,7 @@ public class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingInteg
AnnotationLinkRelationProvider provider = new AnnotationLinkRelationProvider();
mapper.setHandlerInstantiator(new HalHandlerInstantiator(provider, CurieProvider.NONE, MessageResolver.NONE,
mapper.setHandlerInstantiator(new HalHandlerInstantiator(provider, CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY,
new HalConfiguration().withRenderSingleLinksFor("foo", RenderSingleLinks.AS_ARRAY)));
RepresentationModel<?> resource = new RepresentationModel<>();

View File

@@ -56,7 +56,7 @@ class HalFormsMessageConverterUnitTest {
this.mapper.registerModule(new Jackson2HalFormsModule());
this.mapper.setHandlerInstantiator(
new Jackson2HalFormsModule.HalFormsHandlerInstantiator(new AnnotationLinkRelationProvider(), CurieProvider.NONE,
MessageResolver.NONE, true, new HalFormsConfiguration()));
MessageResolver.DEFAULTS_ONLY, true, new HalFormsConfiguration()));
TypeConstrainedMappingJackson2HttpMessageConverter converter = new TypeConstrainedMappingJackson2HttpMessageConverter(
RepresentationModel.class);

View File

@@ -45,7 +45,7 @@ public class HalFormsTemplateBuilderUnitTest {
HalFormsConfiguration configuration = new HalFormsConfiguration();
configuration.registerPattern(CreditCardNumber.class, "[0-9]{16}");
HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, MessageResolver.NONE);
HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, MessageResolver.DEFAULTS_ONLY);
PatternExample resource = new PatternExample();
resource.add(Affordances.of(new Link("/examples")) //
@@ -79,7 +79,7 @@ public class HalFormsTemplateBuilderUnitTest {
.withName("post") //
.toLink());
HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(new HalFormsConfiguration(), MessageResolver.NONE);
HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(new HalFormsConfiguration(), MessageResolver.DEFAULTS_ONLY);
Map<String, HalFormsTemplate> templates = builder.findTemplates(model);

View File

@@ -94,7 +94,7 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra
mapper.registerModule(new Jackson2HalFormsModule());
mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator( //
provider, CurieProvider.NONE, MessageResolver.NONE, true, new HalFormsConfiguration()));
provider, CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY, true, new HalFormsConfiguration()));
mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
}

View File

@@ -66,7 +66,7 @@ class VndErrorsMarshallingTest {
jackson2Mapper = new com.fasterxml.jackson.databind.ObjectMapper();
jackson2Mapper.registerModule(new Jackson2HalModule());
jackson2Mapper.setHandlerInstantiator(
new Jackson2HalModule.HalHandlerInstantiator(relProvider, CurieProvider.NONE, MessageResolver.NONE));
new Jackson2HalModule.HalHandlerInstantiator(relProvider, CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY));
jackson2Mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
VndError error = new VndError("42", "Validation failed!", //

View File

@@ -15,6 +15,8 @@
*/
package org.springframework.hateoas.support;
import java.util.function.Function;
import org.springframework.mock.web.MockServletContext;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
@@ -24,7 +26,7 @@ import org.springframework.web.context.support.AnnotationConfigWebApplicationCon
public class ContextTester {
public static <E extends Exception> void withContext(Class<?> configuration,
ConsumerWithException<AnnotationConfigWebApplicationContext, E> consumer) throws E {
ConsumerWithException<AnnotationConfigWebApplicationContext, E> consumer) throws E {
try (AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext()) {
@@ -36,18 +38,26 @@ public class ContextTester {
}
public static <E extends Exception> void withServletContext(Class<?> configuration,
ConsumerWithException<AnnotationConfigWebApplicationContext, E> consumer) throws E {
ConsumerWithException<AnnotationConfigWebApplicationContext, E> consumer) throws E {
try (AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext()) {
withServletContext(configuration, Function.identity(), consumer);
}
context.register(configuration);
context.setServletContext(new MockServletContext());
context.refresh();
public static <E extends Exception> void withServletContext(Class<?> configuration,
Function<AnnotationConfigWebApplicationContext, AnnotationConfigWebApplicationContext> preparer,
ConsumerWithException<AnnotationConfigWebApplicationContext, E> consumer) throws E {
consumer.accept(context);
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(configuration);
context.setServletContext(new MockServletContext());
try (AnnotationConfigWebApplicationContext prepared = preparer.apply(context)) {
prepared.refresh();
consumer.accept(prepared);
}
}
public interface ConsumerWithException<T, E extends Exception> {
void accept(T element) throws E;