From 2e994f7dc38b5520555720dac215064949ccfabf Mon Sep 17 00:00:00 2001 From: Sebastien Deleuze Date: Fri, 18 Nov 2016 13:46:21 +0100 Subject: [PATCH] Add RedirectView to Spring Web Reactive Main differences with the Spring MVC original implementation: - Default redirect HTTP code is 303 See Other since we can assume all HTTP clients support HTTP 1.1 in 2016 - No more http10Compatible property, use statusCode instead - By default the redirect is relative to the context path - A builder allow to set various properties if needed - In UrlBasedViewResolver, a Function redirectViewProvider property allows to customize RedirectView instances in a flexible way Issue: SPR-14534 --- .../reactive/result/view/RedirectView.java | 365 ++++++++++++++++++ .../result/view/UrlBasedViewResolver.java | 29 +- .../result/view/RedirectViewTests.java | 150 +++++++ .../view/UrlBasedViewResolverTests.java | 47 ++- 4 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/RedirectView.java create mode 100644 spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/RedirectView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/RedirectView.java new file mode 100644 index 0000000000..135f6f3eb2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/RedirectView.java @@ -0,0 +1,365 @@ +/* + * Copyright 2002-2016 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.io.UnsupportedEncodingException; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +/** + * View that redirects to an absolute or context relative URL. The URL may be a URI + * template in which case the URI template variables will be replaced with values + * available in the model. + * + *

A URL for this view is supposed to be a HTTP redirect which does the redirect via + * sending an {@link HttpStatus#SEE_OTHER} code. If HTTP 1.0 compatibility is needed, + * {@link HttpStatus#FOUND} code can be set via {@link #setStatusCode(HttpStatus)}. + * + *

Note that the default value for the "contextRelative" flag is true. + * With the flag on, URLs starting with "/" are considered relative to the web application + * context path, while with this flag off they are considered relative to the web server + * root. + * + * @author Sebastien Deleuze + * @see #setContextRelative + * @since 5.0 + */ +public class RedirectView extends AbstractUrlBasedView { + + private static final Pattern URI_TEMPLATE_VARIABLE_PATTERN = Pattern.compile("\\{([^/]+?)\\}"); + + + private boolean contextRelative = true; + + private HttpStatus statusCode = HttpStatus.SEE_OTHER; + + private boolean propagateQueryParams = false; + + private String[] hosts; + + + /** + * Create a new {@code RedirectView} with the given redirect URL. + * + * @see #builder(String) + */ + public RedirectView(String redirectUrl) { + super(redirectUrl); + } + + + /** + * Return a builder for a {@code RedirectView}. + */ + public static Builder builder(String redirectUrl) { + return new BuilderImpl(redirectUrl); + } + + + /** + * Set whether to interpret a given URL that starts with a slash ("/") + * as relative to the current context path. + *

Default is "true": the context path will be + * prepended to the URL in such a case. If "false", an URL that starts + * with a slash will be interpreted as absolute, i.e. taken as-is. + */ + public void setContextRelative(boolean contextRelative) { + this.contextRelative = contextRelative; + } + + /** + * Set a customized redirect status code to be used for a redirect. Default is + * {@link HttpStatus#SEE_OTHER} which is the correct code for HTTP 1.1 + * clients. This setter can be used to configure {@link HttpStatus#FOUND} + * if HTTP 1.0 clients need to be supported, or any other {@literal 3xx} + * status code. + */ + public void setStatusCode(HttpStatus statusCode) { + Assert.notNull(statusCode); + this.statusCode = statusCode; + } + + /** + * Get the redirect status code. + */ + public HttpStatus getStatusCode() { + return statusCode; + } + + /** + * When set to {@code true} the query string of the current URL is appended + * and thus propagated through to the redirected URL. + *

Defaults to {@code false}. + */ + public void setPropagateQueryParams(boolean propagateQueryParams) { + this.propagateQueryParams = propagateQueryParams; + } + + /** + * Configure one or more hosts associated with the application. + * All other hosts will be considered external hosts. + *

In effect, this property provides a way turn off encoding via + * {@link javax.servlet.http.HttpServletResponse#encodeRedirectURL} for URLs that have a + * host and that host is not listed as a known host when using a Servlet based engine. + *

If not set (the default) all URLs are encoded through the response. + * @param hosts one or more application hosts + */ + public void setHosts(String... hosts) { + this.hosts = hosts; + } + + /** + * Convert model to request parameters and redirect to the given URL. + * @see #sendRedirect + */ + @Override + protected Mono renderInternal(Map model, MediaType contentType, + ServerWebExchange exchange) { + String targetUrl = createTargetUrl(model, exchange); + return sendRedirect(exchange, targetUrl); + } + + /** + * Create the target URL by checking if the redirect string is a URI template first, + * expanding it with the given model, and then optionally appending simple type model + * attributes as query String parameters. + */ + protected final String createTargetUrl(Map model, ServerWebExchange exchange) { + + ServerHttpRequest request = exchange.getRequest(); + // Prepare target URL. + StringBuilder targetUrl = new StringBuilder(); + if (this.contextRelative && getUrl().startsWith("/")) { + // Do not apply context path to relative URLs. + targetUrl.append(request.getContextPath()); + } + targetUrl.append(getUrl()); + + Charset charset = this.getDefaultCharset(); + if (StringUtils.hasText(targetUrl)) { + Map variables = getCurrentRequestUriVariables(exchange); + targetUrl = replaceUriTemplateVariables(targetUrl.toString(), model, variables, charset); + } + if (this.propagateQueryParams) { + appendCurrentQueryParams(targetUrl, request); + } + + return targetUrl.toString(); + } + + /** + * Replace URI template variables in the target URL with encoded model + * attributes or URI variables from the current request. Model attributes + * referenced in the URL are removed from the model. + * @param targetUrl the redirect URL + * @param model Map that contains model attributes + * @param currentUriVariables current request URI variables to use + * @param charset the charset to use + */ + protected StringBuilder replaceUriTemplateVariables(String targetUrl, + Map model, Map currentUriVariables, Charset charset) { + + StringBuilder result = new StringBuilder(); + Matcher matcher = URI_TEMPLATE_VARIABLE_PATTERN.matcher(targetUrl); + int endLastMatch = 0; + while (matcher.find()) { + String name = matcher.group(1); + Object value = (model.containsKey(name) ? model.remove(name) : currentUriVariables.get(name)); + if (value == null) { + throw new IllegalArgumentException("Model has no value for key '" + name + "'"); + } + result.append(targetUrl.substring(endLastMatch, matcher.start())); + try { + result.append(UriUtils.encodePathSegment(value.toString(), charset.name())); + } + catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex); + } + endLastMatch = matcher.end(); + } + result.append(targetUrl.substring(endLastMatch, targetUrl.length())); + return result; + } + + @SuppressWarnings("unchecked") + private Map getCurrentRequestUriVariables(ServerWebExchange exchange) { + String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + return (Map) exchange.getAttribute(name).orElse(Collections.emptyMap()); + } + + /** + * Append the query string of the current request to the target redirect URL. + * @param targetUrl the StringBuilder to append the properties to + * @param request the current request + */ + protected void appendCurrentQueryParams(StringBuilder targetUrl, ServerHttpRequest request) { + String query = request.getURI().getQuery(); + if (StringUtils.hasText(query)) { + // Extract anchor fragment, if any. + String fragment = null; + int anchorIndex = targetUrl.indexOf("#"); + if (anchorIndex > -1) { + fragment = targetUrl.substring(anchorIndex); + targetUrl.delete(anchorIndex, targetUrl.length()); + } + + if (targetUrl.toString().indexOf('?') < 0) { + targetUrl.append('?').append(query); + } + else { + targetUrl.append('&').append(query); + } + // Append anchor fragment, if any, to end of URL. + if (fragment != null) { + targetUrl.append(fragment); + } + } + } + + /** + * Send a redirect back to the HTTP client + * @param exchange current HTTP exchange + * @param targetUrl the target URL to redirect to + */ + protected Mono sendRedirect(ServerWebExchange exchange, String targetUrl) { + ServerHttpResponse response = exchange.getResponse(); + // TODO Support encoding redirect URL as ServerHttpResponse level when SPR-14529 will be fixed + response.getHeaders().setLocation(URI.create(targetUrl)); + response.setStatusCode(this.statusCode); + return Mono.empty(); + } + + /** + * Whether the given targetUrl has a host that is a "foreign" system in which + * case {@link javax.servlet.http.HttpServletResponse#encodeRedirectURL} will not be applied. + * This method returns {@code true} if the {@link #setHosts(String[])} + * property is configured and the target URL has a host that does not match. + * @param targetUrl the target redirect URL + * @return {@code true} the target URL has a remote host, {@code false} if it + * the URL does not have a host or the "host" property is not configured. + */ + protected boolean isRemoteHost(String targetUrl) { + if (ObjectUtils.isEmpty(this.hosts)) { + return false; + } + String targetHost = UriComponentsBuilder.fromUriString(targetUrl).build().getHost(); + if (StringUtils.isEmpty(targetHost)) { + return false; + } + for (String host : this.hosts) { + if (targetHost.equals(host)) { + return false; + } + } + return true; + } + + @Override + public boolean checkResourceExists(Locale locale) throws Exception { + return true; + } + + public interface Builder { + + /** + * @see RedirectView#setContextRelative(boolean) + */ + Builder contextRelative(boolean contextRelative); + + /** + * @see RedirectView#setStatusCode(HttpStatus) + */ + Builder statusCode(HttpStatus statusCode); + + /** + * @see RedirectView#setPropagateQueryParams(boolean) + */ + Builder propagateQueryParams(boolean propagateQueryParams); + + /** + * @see RedirectView#setHosts(String...) + */ + Builder hosts(String... hosts); + + /** + * Build the redirect view. + */ + RedirectView build(); + + } + + private static class BuilderImpl implements Builder { + + private final RedirectView view; + + + public BuilderImpl(String redirectUrl) { + this.view = new RedirectView(redirectUrl); + } + + + @Override + public Builder contextRelative(boolean contextRelative) { + this.view.setContextRelative(contextRelative); + return this; + } + + @Override + public Builder statusCode(HttpStatus statusCode) { + this.view.setStatusCode(statusCode); + return this; + } + + @Override + public Builder propagateQueryParams(boolean propagateQueryParams) { + this.view.setPropagateQueryParams(propagateQueryParams); + return this; + } + + @Override + public Builder hosts(String... hosts) { + this.view.setHosts(hosts); + return this; + } + + @Override + public RedirectView build() { + return this.view; + } + + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/UrlBasedViewResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/UrlBasedViewResolver.java index 2836819099..570f2757cd 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/UrlBasedViewResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/UrlBasedViewResolver.java @@ -17,6 +17,7 @@ package org.springframework.web.reactive.result.view; import java.util.Locale; +import java.util.function.Function; import reactor.core.publisher.Mono; @@ -56,6 +57,15 @@ import org.springframework.util.PatternMatchUtils; */ public class UrlBasedViewResolver extends ViewResolverSupport implements ViewResolver, InitializingBean { + /** + * Prefix for special view names that specify a redirect URL (usually + * to a controller after a form has been submitted and processed). + * Such view names will not be resolved in the configured default + * way but rather be treated as special shortcut. + */ + public static final String REDIRECT_URL_PREFIX = "redirect:"; + + private Class viewClass; private String prefix = ""; @@ -64,6 +74,8 @@ public class UrlBasedViewResolver extends ViewResolverSupport implements ViewRes private String[] viewNames; + private Function redirectViewProvider = url -> new RedirectView(url); + /** * Set the view class to instantiate through {@link #createUrlBasedView(String)}. @@ -141,6 +153,13 @@ public class UrlBasedViewResolver extends ViewResolverSupport implements ViewRes return this.viewNames; } + /** + * URL based {@link RedirectView} provider which can be used to provide, for example, + * redirect views with a custom default status code. + */ + public void setRedirectViewProvider(Function redirectViewProvider) { + this.redirectViewProvider = redirectViewProvider; + } @Override public void afterPropertiesSet() throws Exception { @@ -149,13 +168,19 @@ public class UrlBasedViewResolver extends ViewResolverSupport implements ViewRes } } - @Override public Mono resolveViewName(String viewName, Locale locale) { if (!canHandle(viewName, locale)) { return Mono.empty(); } - AbstractUrlBasedView urlBasedView = createUrlBasedView(viewName); + AbstractUrlBasedView urlBasedView; + if (viewName.startsWith(REDIRECT_URL_PREFIX)) { + String redirectUrl = viewName.substring(REDIRECT_URL_PREFIX.length()); + urlBasedView = this.redirectViewProvider.apply(redirectUrl); + } + else { + urlBasedView = createUrlBasedView(viewName); + } View view = applyLifecycleMethods(viewName, urlBasedView); try { return (urlBasedView.checkResourceExists(locale) ? Mono.just(view) : Mono.empty()); diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java new file mode 100644 index 0000000000..7d4fa194fd --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2002-2016 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.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.*; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Tests for redirect view, and query string construction. + * Doesn't test URL encoding, although it does check that it's called. + * + * @author Sebastien Deleuze + */ +public class RedirectViewTests { + + private MockServerHttpRequest request; + + private MockServerHttpResponse response; + + private ServerWebExchange exchange; + + + @Before + public void setUp() { + request = new MockServerHttpRequest(); + request.setContextPath("/context"); + response = new MockServerHttpResponse(); + WebSessionManager sessionManager = new DefaultWebSessionManager(); + exchange = new DefaultServerWebExchange(request, response, sessionManager); + } + + @Test(expected = IllegalArgumentException.class) + public void noUrlSet() throws Exception { + RedirectView rv = new RedirectView(null); + rv.afterPropertiesSet(); + } + + @Test + public void defaultStatusCode() { + String url = "http://url.somewhere.com"; + RedirectView view = new RedirectView(url); + view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange); + assertEquals(HttpStatus.SEE_OTHER, response.getStatusCode()); + assertEquals(URI.create(url), response.getHeaders().getLocation()); + } + + @Test + public void customStatusCode() { + RedirectView view = RedirectView + .builder("http://url.somewhere.com") + .statusCode(HttpStatus.FOUND) + .build(); + view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange); + assertEquals(HttpStatus.FOUND, response.getStatusCode()); + assertEquals(URI.create("http://url.somewhere.com"), response.getHeaders().getLocation()); + } + + @Test + public void contextRelative() { + String url = "/test.html"; + RedirectView view = new RedirectView(url); + view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange); + assertEquals(URI.create("/context/test.html"), response.getHeaders().getLocation()); + } + + @Test + public void contextRelativeQueryParam() { + String url = "/test.html?id=1"; + RedirectView view = new RedirectView(url); + view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange); + assertEquals(URI.create("/context/test.html?id=1"), response.getHeaders().getLocation()); + } + + @Test + public void remoteHost() { + RedirectView view = new RedirectView(""); + + assertFalse(view.isRemoteHost("http://url.somewhere.com")); + assertFalse(view.isRemoteHost("/path")); + assertFalse(view.isRemoteHost("http://url.somewhereelse.com")); + + view.setHosts(new String[] {"url.somewhere.com"}); + + assertFalse(view.isRemoteHost("http://url.somewhere.com")); + assertFalse(view.isRemoteHost("/path")); + assertTrue(view.isRemoteHost("http://url.somewhereelse.com")); + } + + @Test + public void expandUriTemplateVariablesFromModel() { + String url = "http://url.somewhere.com?foo={foo}"; + Map model = Collections.singletonMap("foo", "bar"); + RedirectView view = new RedirectView(url); + view.render(model, MediaType.TEXT_HTML, exchange); + assertEquals(URI.create("http://url.somewhere.com?foo=bar"), response.getHeaders().getLocation()); + } + + @Test + public void expandUriTemplateVariablesFromExchangeAttribute() { + String url = "http://url.somewhere.com?foo={foo}"; + Map attributes = Collections.singletonMap("foo", "bar"); + exchange.getAttributes().put(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, attributes); + RedirectView view = new RedirectView(url); + view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange); + assertEquals(URI.create("http://url.somewhere.com?foo=bar"), response.getHeaders().getLocation()); + } + + @Test + public void propagateQueryParams() throws Exception { + RedirectView view = RedirectView + .builder("http://url.somewhere.com?foo=bar#bazz") + .propagateQueryParams(true) + .build(); + request.setUri(URI.create("http://url.somewhere.com?a=b&c=d")); + view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange); + assertEquals(HttpStatus.SEE_OTHER, response.getStatusCode()); + assertEquals(URI.create("http://url.somewhere.com?foo=bar&a=b&c=d#bazz"), response.getHeaders().getLocation()); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/UrlBasedViewResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/UrlBasedViewResolverTests.java index 120d685299..2f5cad661c 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/UrlBasedViewResolverTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/UrlBasedViewResolverTests.java @@ -19,33 +19,40 @@ package org.springframework.web.reactive.result.view; import java.util.Locale; import java.util.Map; +import static org.junit.Assert.*; +import org.junit.Before; import org.junit.Test; import reactor.core.publisher.Mono; import org.springframework.context.support.StaticApplicationContext; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.web.server.ServerWebExchange; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; +import reactor.test.StepVerifier; /** * Unit tests for {@link UrlBasedViewResolver}. * * @author Rossen Stoyanchev + * @author Sebastien Deleuze */ public class UrlBasedViewResolverTests { + private UrlBasedViewResolver resolver; - @Test - public void viewNames() throws Exception { + @Before + public void setUp() { StaticApplicationContext context = new StaticApplicationContext(); context.refresh(); + resolver = new UrlBasedViewResolver(); + resolver.setApplicationContext(context); + } - UrlBasedViewResolver resolver = new UrlBasedViewResolver(); + @Test + public void viewNames() throws Exception { resolver.setViewClass(TestView.class); resolver.setViewNames("my*"); - resolver.setApplicationContext(context); Mono mono = resolver.resolveViewName("my-view", Locale.US); assertNotNull(mono.block()); @@ -54,6 +61,34 @@ public class UrlBasedViewResolverTests { assertNull(mono.block()); } + @Test + public void redirectView() throws Exception { + Mono mono = resolver.resolveViewName("redirect:foo", Locale.US); + assertNotNull(mono.block()); + StepVerifier.create(mono) + .consumeNextWith(view -> { + assertEquals(RedirectView.class, view.getClass()); + RedirectView redirectView = (RedirectView) view; + assertEquals(redirectView.getUrl(), "foo"); + assertEquals(redirectView.getStatusCode(), HttpStatus.SEE_OTHER); + }) + .expectComplete(); + } + + @Test + public void customizedRedirectView() throws Exception { + resolver.setRedirectViewProvider(url -> RedirectView.builder(url).statusCode(HttpStatus.FOUND).build()); + Mono mono = resolver.resolveViewName("redirect:foo", Locale.US); + assertNotNull(mono.block()); + StepVerifier.create(mono) + .consumeNextWith(view -> { + assertEquals(RedirectView.class, view.getClass()); + RedirectView redirectView = (RedirectView) view; + assertEquals(redirectView.getUrl(), "foo"); + assertEquals(redirectView.getStatusCode(), HttpStatus.FOUND); + }) + .expectComplete(); + } private static class TestView extends AbstractUrlBasedView { -- GitLab