Introduce LocaleContextResolver in WebFlux

This commit introduces LocaleContextResolver interface, which is used
at ServerWebExchange level to resolve Locale, TimeZone and other i18n
related informations.

It follows Spring MVC locale resolution patterns with a few differences:
 - Only LocaleContextResolver is supported since LocaleResolver is less
   flexible
 - Support is implemented in the org.springframework.web.server.i18n
   package of spring-web module rather than in spring-webflux in order
   to be able to leverage it at ServerWebExchange level

2 implementations are provided:
 - FixedLocaleContextResolver
 - AcceptHeaderLocaleContextResolver

It can be configured with both functional or annotation-based APIs.

Issue: SPR-15036
This commit is contained in:
Sebastien Deleuze
2017-06-06 09:31:58 +02:00
parent 72a8868f84
commit e0e6736bc5
28 changed files with 853 additions and 27 deletions

View File

@@ -58,9 +58,11 @@ import org.springframework.web.reactive.result.method.annotation.ResponseBodyRes
import org.springframework.web.reactive.result.method.annotation.ResponseEntityResultHandler;
import org.springframework.web.reactive.result.view.ViewResolutionResultHandler;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.i18n.LocaleContextResolver;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebExceptionHandler;
import org.springframework.web.server.handler.ResponseStatusExceptionHandler;
import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver;
/**
* The main class for Spring WebFlux configuration.
@@ -266,6 +268,18 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware {
return serverCodecConfigurer;
}
/**
* Override to plug a sub-class of {@link LocaleContextResolver}.
*/
protected LocaleContextResolver createLocaleContextResolver() {
return new AcceptHeaderLocaleContextResolver();
}
@Bean
public LocaleContextResolver localeContextResolver() {
return createLocaleContextResolver();
}
/**
* Override to configure the HTTP message readers and writers to use.
*/

View File

@@ -27,9 +27,11 @@ import org.springframework.http.codec.HttpMessageWriter;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.util.Assert;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.i18n.LocaleContextResolver;
import org.springframework.web.server.WebExceptionHandler;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.handler.ResponseStatusExceptionHandler;
import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver;
/**
* Default implementation of {@link HandlerStrategies.Builder}.
@@ -47,6 +49,8 @@ class DefaultHandlerStrategiesBuilder implements HandlerStrategies.Builder {
private final List<WebExceptionHandler> exceptionHandlers = new ArrayList<>();
private LocaleContextResolver localeContextResolver;
public DefaultHandlerStrategiesBuilder() {
this.codecConfigurer.registerDefaults(false);
@@ -55,6 +59,7 @@ class DefaultHandlerStrategiesBuilder implements HandlerStrategies.Builder {
public void defaultConfiguration() {
this.codecConfigurer.registerDefaults(true);
exceptionHandler(new ResponseStatusExceptionHandler());
localeContextResolver(new AcceptHeaderLocaleContextResolver());
}
@Override
@@ -94,11 +99,18 @@ class DefaultHandlerStrategiesBuilder implements HandlerStrategies.Builder {
return this;
}
@Override
public HandlerStrategies.Builder localeContextResolver(LocaleContextResolver localeContextResolver) {
Assert.notNull(localeContextResolver, "'localeContextResolver' must not be null");
this.localeContextResolver = localeContextResolver;
return this;
}
@Override
public HandlerStrategies build() {
return new DefaultHandlerStrategies(this.codecConfigurer.getReaders(),
this.codecConfigurer.getWriters(), this.viewResolvers, this.webFilters,
this.exceptionHandlers);
this.exceptionHandlers, this.localeContextResolver);
}
@@ -114,18 +126,23 @@ class DefaultHandlerStrategiesBuilder implements HandlerStrategies.Builder {
private final List<WebExceptionHandler> exceptionHandlers;
private final LocaleContextResolver localeContextResolver;
public DefaultHandlerStrategies(
List<HttpMessageReader<?>> messageReaders,
List<HttpMessageWriter<?>> messageWriters,
List<ViewResolver> viewResolvers,
List<WebFilter> webFilters,
List<WebExceptionHandler> exceptionHandlers) {
List<WebExceptionHandler> exceptionHandlers,
LocaleContextResolver localeContextResolver) {
this.messageReaders = unmodifiableCopy(messageReaders);
this.messageWriters = unmodifiableCopy(messageWriters);
this.viewResolvers = unmodifiableCopy(viewResolvers);
this.webFilters = unmodifiableCopy(webFilters);
this.exceptionHandlers = unmodifiableCopy(exceptionHandlers);
this.localeContextResolver = localeContextResolver;
}
private static <T> List<T> unmodifiableCopy(List<? extends T> list) {
@@ -156,6 +173,11 @@ class DefaultHandlerStrategiesBuilder implements HandlerStrategies.Builder {
public List<WebExceptionHandler> exceptionHandlers() {
return this.exceptionHandlers;
}
@Override
public LocaleContextResolver localeContextResolver() {
return this.localeContextResolver;
}
}
}

View File

@@ -20,7 +20,6 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Stream;
@@ -156,7 +155,7 @@ class DefaultRenderingResponseBuilder implements RenderingResponse.Builder {
ServerHttpResponse response = exchange.getResponse();
writeStatusAndHeaders(response);
MediaType contentType = exchange.getResponse().getHeaders().getContentType();
Locale locale = resolveLocale(exchange);
Locale locale = exchange.getLocaleContext().getLocale();
Stream<ViewResolver> viewResolverStream = context.viewResolvers().stream();
return Flux.fromStream(viewResolverStream)
@@ -167,11 +166,6 @@ class DefaultRenderingResponseBuilder implements RenderingResponse.Builder {
.flatMap(view -> view.render(model(), contentType, exchange));
}
private Locale resolveLocale(ServerWebExchange exchange) {
List<Locale> locales = exchange.getRequest().getHeaders().getAcceptLanguageAsLocales();
return locales.isEmpty() ? Locale.getDefault() : locales.get(0);
}
}
}

View File

@@ -24,6 +24,7 @@ import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.HttpMessageWriter;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.i18n.LocaleContextResolver;
import org.springframework.web.server.WebExceptionHandler;
import org.springframework.web.server.WebFilter;
@@ -35,6 +36,7 @@ import org.springframework.web.server.WebFilter;
*
* @author Arjen Poutsma
* @author Juergen Hoeller
* @author Sebastien Deleuze
* @since 5.0
* @see RouterFunctions#toHttpHandler(RouterFunction, HandlerStrategies)
*/
@@ -72,6 +74,12 @@ public interface HandlerStrategies {
*/
List<WebExceptionHandler> exceptionHandlers();
/**
* Return the {@link LocaleContextResolver} to be used for resolving locale context.
* @return the locale context resolver
*/
LocaleContextResolver localeContextResolver();
// Static methods
@@ -146,6 +154,13 @@ public interface HandlerStrategies {
*/
Builder exceptionHandler(WebExceptionHandler exceptionHandler);
/**
* Add the given locale context resolver to this builder.
* @param localeContextResolver the locale context resolver to add
* @return this builder
*/
Builder localeContextResolver(LocaleContextResolver localeContextResolver);
/**
* Builds the {@link HandlerStrategies}.
* @return the built strategies

View File

@@ -199,6 +199,7 @@ public abstract class RouterFunctions {
return WebHttpHandlerBuilder.webHandler(webHandler)
.filters(strategies.webFilters())
.exceptionHandlers(strategies.exceptionHandlers())
.localeContextResolver(strategies.localeContextResolver())
.build();
}

View File

@@ -25,6 +25,8 @@ import java.util.TimeZone;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.i18n.LocaleContext;
import org.springframework.context.i18n.TimeZoneAwareLocaleContext;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -84,9 +86,10 @@ public class RequestContext {
this.model = model;
this.messageSource = messageSource;
List<Locale> locales = exchange.getRequest().getHeaders().getAcceptLanguageAsLocales();
this.locale = locales.isEmpty() ? Locale.getDefault() : locales.get(0);
this.timeZone = TimeZone.getDefault(); // TODO
LocaleContext localeContext = exchange.getLocaleContext();
this.locale = localeContext.getLocale();
this.timeZone = (localeContext instanceof TimeZoneAwareLocaleContext ?
((TimeZoneAwareLocaleContext)localeContext).getTimeZone() : TimeZone.getDefault());
this.defaultHtmlEscape = null; // TODO
this.dataValueProcessor = dataValueProcessor;

View File

@@ -201,8 +201,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport
Model model = result.getModel();
MethodParameter parameter = result.getReturnTypeSource();
List<Locale> locales = exchange.getRequest().getHeaders().getAcceptLanguageAsLocales();
Locale locale = locales.isEmpty() ? Locale.getDefault() : locales.get(0);
Locale locale = exchange.getLocaleContext().getLocale();
Class<?> clazz = valueType.getRawClass();
if (clazz == null) {

View File

@@ -21,7 +21,6 @@ import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@@ -189,8 +188,7 @@ public class FreeMarkerView extends AbstractUrlBasedView {
logger.debug("Rendering FreeMarker template [" + getUrl() + "].");
}
List<Locale> locales = exchange.getRequest().getHeaders().getAcceptLanguageAsLocales();
Locale locale = locales.isEmpty() ? Locale.getDefault() : locales.get(0);
Locale locale = exchange.getLocaleContext().getLocale();
DataBuffer dataBuffer = exchange.getResponse().bufferFactory().allocateBuffer();
try {

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2002-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.function.server;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.result.view.View;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.i18n.FixedLocaleContextResolver;
import static org.junit.Assert.assertEquals;
/**
* @author Sebastien Deleuze
*/
public class LocaleContextResolverIntegrationTests extends AbstractRouterFunctionIntegrationTests {
private final WebClient webClient = WebClient.create();
@Test
public void fixedLocale() {
Mono<ClientResponse> result = webClient
.get()
.uri("http://localhost:" + this.port + "/")
.exchange();
StepVerifier
.create(result)
.consumeNextWith(response -> {
assertEquals(HttpStatus.OK, response.statusCode());
assertEquals(Locale.GERMANY, response.headers().asHttpHeaders().getContentLanguage());
})
.verifyComplete();
}
@Override
protected RouterFunction<?> routerFunction() {
return RouterFunctions.route(RequestPredicates.path("/"), this::render);
}
public Mono<RenderingResponse> render(ServerRequest request) {
return RenderingResponse.create("foo").build();
}
@Override
protected HandlerStrategies handlerStrategies() {
return HandlerStrategies.builder()
.viewResolver(new DummyViewResolver())
.localeContextResolver(new FixedLocaleContextResolver(Locale.GERMANY))
.build();
}
private static class DummyViewResolver implements ViewResolver {
@Override
public Mono<View> resolveViewName(String viewName, Locale locale) {
return Mono.just(new DummyView(locale));
}
}
private static class DummyView implements View {
private final Locale locale;
public DummyView(Locale locale) {
this.locale = locale;
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return Collections.singletonList(MediaType.TEXT_HTML);
}
@Override
public Mono<Void> render(@Nullable Map<String, ?> model, @Nullable MediaType contentType,
ServerWebExchange exchange) {
exchange.getResponse().getHeaders().setContentLanguage(locale);
return Mono.empty();
}
}
}

View File

@@ -43,6 +43,7 @@ import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException;
import org.springframework.web.server.WebSession;
import org.springframework.web.server.adapter.DefaultServerWebExchange;
import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver;
import org.springframework.web.server.session.MockWebSessionManager;
import org.springframework.web.server.session.WebSessionManager;
@@ -82,7 +83,8 @@ public class SessionAttributeMethodArgumentResolverTests {
WebSessionManager sessionManager = new MockWebSessionManager(this.session);
ServerHttpRequest request = MockServerHttpRequest.get("/").build();
this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager, ServerCodecConfigurer.create());
this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(),
sessionManager, ServerCodecConfigurer.create(), new AcceptHeaderLocaleContextResolver());
this.handleMethod = ReflectionUtils.findMethod(getClass(), "handleWithSessionAttribute", (Class<?>[]) null);
}

View File

@@ -31,6 +31,7 @@ import org.springframework.web.reactive.BindingContext;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession;
import org.springframework.web.server.adapter.DefaultServerWebExchange;
import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver;
import org.springframework.web.server.session.DefaultWebSession;
import org.springframework.web.server.session.WebSessionManager;
@@ -65,7 +66,7 @@ public class WebSessionArgumentResolverTests {
WebSessionManager manager = exchange -> Mono.just(session);
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
ServerWebExchange exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(),
manager, ServerCodecConfigurer.create());
manager, ServerCodecConfigurer.create(), new AcceptHeaderLocaleContextResolver());
MethodParameter param = this.testMethod.arg(WebSession.class);
Object actual = this.resolver.resolveArgument(param, context, exchange).block();

View File

@@ -0,0 +1,129 @@
/*
* Copyright 2002-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.result.view;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.reactive.config.ViewResolverRegistry;
import org.springframework.web.reactive.config.WebFluxConfigurationSupport;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.result.method.annotation.AbstractRequestMappingIntegrationTests;
import org.springframework.web.server.i18n.LocaleContextResolver;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.i18n.FixedLocaleContextResolver;
import static org.junit.Assert.assertEquals;
/**
* @author Sebastien Deleuze
*/
public class LocaleContextResolverIntegrationTests extends AbstractRequestMappingIntegrationTests {
private final WebClient webClient = WebClient.create();
@Test
public void fixedLocale() {
Mono<ClientResponse> result = webClient
.get()
.uri("http://localhost:" + this.port + "/")
.exchange();
StepVerifier
.create(result)
.consumeNextWith(response -> {
assertEquals(HttpStatus.OK, response.statusCode());
assertEquals(Locale.GERMANY, response.headers().asHttpHeaders().getContentLanguage());
})
.verifyComplete();
}
@Override
protected ApplicationContext initApplicationContext() {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.register(WebConfig.class);
context.refresh();
return context;
}
@Configuration
@ComponentScan(resourcePattern = "**/LocaleContextResolverIntegrationTests*.class")
@SuppressWarnings({"unused", "WeakerAccess"})
static class WebConfig extends WebFluxConfigurationSupport {
@Override
protected LocaleContextResolver createLocaleContextResolver() {
return new FixedLocaleContextResolver(Locale.GERMANY);
}
@Override
protected void configureViewResolvers(ViewResolverRegistry registry) {
registry.viewResolver((viewName, locale) -> Mono.just(new DummyView(locale)));
}
private static class DummyView implements View {
private final Locale locale;
public DummyView(Locale locale) {
this.locale = locale;
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return Collections.singletonList(MediaType.TEXT_HTML);
}
@Override
public Mono<Void> render(@Nullable Map<String, ?> model, @Nullable MediaType contentType,
ServerWebExchange exchange) {
exchange.getResponse().getHeaders().setContentLanguage(locale);
return Mono.empty();
}
}
}
@Controller
@SuppressWarnings("unused")
static class TestController {
@GetMapping("/")
public String foo() {
return "foo";
}
}
}