org.springframework.web.servlet.view.RedirectView Maven / Gradle / Ivy
/*
* Copyright 2002-2023 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
*
* https://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.servlet.view;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Array;
import java.net.URLEncoder;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.BeanUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.SmartView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.support.RequestContextUtils;
import org.springframework.web.servlet.support.RequestDataValueProcessor;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import org.springframework.web.util.WebUtils;
/**
* View that redirects to an absolute, context relative, or current request
* 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. By default,
* all primitive model attributes (or collections thereof) are exposed as HTTP
* query parameters (assuming they've not been used as URI template variables),
* but this behavior can be changed by overriding the
* {@link #isEligibleProperty(String, Object)} method.
*
* A URL for this view is supposed to be an HTTP redirect URL, i.e.
* suitable for HttpServletResponse's {@code sendRedirect} method, which
* is what actually does the redirect if the HTTP 1.0 flag is on, or via sending
* back an HTTP 303 code - if the HTTP 1.0 compatibility flag is off.
*
*
Note that while the default value for the "contextRelative" flag is off,
* you will probably want to almost always set it to true. With the flag off,
* URLs starting with "/" are considered relative to the web server root, while
* with the flag on, they are considered relative to the web application root.
* Since most web applications will never know or care what their context path
* actually is, they are much better off setting this flag to true, and submitting
* paths which are to be considered relative to the web application root.
*
*
NOTE when using this redirect view in a Portlet environment: Make sure
* that your controller respects the Portlet {@code sendRedirect} constraints.
*
* @author Rod Johnson
* @author Juergen Hoeller
* @author Colin Sampaleanu
* @author Sam Brannen
* @author Arjen Poutsma
* @author Rossen Stoyanchev
* @see #setContextRelative
* @see #setHttp10Compatible
* @see #setExposeModelAttributes
* @see jakarta.servlet.http.HttpServletResponse#sendRedirect
*/
public class RedirectView extends AbstractUrlBasedView implements SmartView {
private static final Pattern URI_TEMPLATE_VARIABLE_PATTERN = Pattern.compile("\\{([^/]+?)\\}");
private boolean contextRelative = false;
private boolean http10Compatible = true;
private boolean exposeModelAttributes = true;
@Nullable
private String encodingScheme;
@Nullable
private HttpStatusCode statusCode;
private boolean expandUriTemplateVariables = true;
private boolean propagateQueryParams = false;
@Nullable
private String[] hosts;
/**
* Constructor for use as a bean.
*/
public RedirectView() {
setExposePathVariables(false);
}
/**
* Create a new RedirectView with the given URL.
*
The given URL will be considered as relative to the web server,
* not as relative to the current ServletContext.
* @param url the URL to redirect to
* @see #RedirectView(String, boolean)
*/
public RedirectView(String url) {
super(url);
setExposePathVariables(false);
}
/**
* Create a new RedirectView with the given URL.
* @param url the URL to redirect to
* @param contextRelative whether to interpret the given URL as
* relative to the current ServletContext
*/
public RedirectView(String url, boolean contextRelative) {
super(url);
this.contextRelative = contextRelative;
setExposePathVariables(false);
}
/**
* Create a new RedirectView with the given URL.
* @param url the URL to redirect to
* @param contextRelative whether to interpret the given URL as
* relative to the current ServletContext
* @param http10Compatible whether to stay compatible with HTTP 1.0 clients
*/
public RedirectView(String url, boolean contextRelative, boolean http10Compatible) {
super(url);
this.contextRelative = contextRelative;
this.http10Compatible = http10Compatible;
setExposePathVariables(false);
}
/**
* Create a new RedirectView with the given URL.
* @param url the URL to redirect to
* @param contextRelative whether to interpret the given URL as
* relative to the current ServletContext
* @param http10Compatible whether to stay compatible with HTTP 1.0 clients
* @param exposeModelAttributes whether model attributes should be
* exposed as query parameters
*/
public RedirectView(String url, boolean contextRelative, boolean http10Compatible, boolean exposeModelAttributes) {
super(url);
this.contextRelative = contextRelative;
this.http10Compatible = http10Compatible;
this.exposeModelAttributes = exposeModelAttributes;
setExposePathVariables(false);
}
/**
* Set whether to interpret a given URL that starts with a slash ("/")
* as relative to the current ServletContext, i.e. as relative to the
* web application root.
*
Default is "false": A URL that starts with a slash will be interpreted
* as absolute, i.e. taken as-is. If "true", the context path will be
* prepended to the URL in such a case.
* @see jakarta.servlet.http.HttpServletRequest#getContextPath
*/
public void setContextRelative(boolean contextRelative) {
this.contextRelative = contextRelative;
}
/**
* Set whether to stay compatible with HTTP 1.0 clients.
*
In the default implementation, this will enforce HTTP status code 302
* in any case, i.e. delegate to {@code HttpServletResponse.sendRedirect}.
* Turning this off will send HTTP status code 303, which is the correct
* code for HTTP 1.1 clients, but not understood by HTTP 1.0 clients.
*
Many HTTP 1.1 clients treat 302 just like 303, not making any
* difference. However, some clients depend on 303 when redirecting
* after a POST request; turn this flag off in such a scenario.
* @see jakarta.servlet.http.HttpServletResponse#sendRedirect
*/
public void setHttp10Compatible(boolean http10Compatible) {
this.http10Compatible = http10Compatible;
}
/**
* Set the {@code exposeModelAttributes} flag which denotes whether
* model attributes should be exposed as HTTP query parameters.
*
Defaults to {@code true}.
*/
public void setExposeModelAttributes(final boolean exposeModelAttributes) {
this.exposeModelAttributes = exposeModelAttributes;
}
/**
* Set the encoding scheme for this view.
*
Default is the request's encoding scheme
* (which is ISO-8859-1 if not specified otherwise).
*/
public void setEncodingScheme(String encodingScheme) {
this.encodingScheme = encodingScheme;
}
/**
* Set the status code for this view.
*
Default is to send 302/303, depending on the value of the
* {@link #setHttp10Compatible(boolean) http10Compatible} flag.
*/
public void setStatusCode(HttpStatusCode statusCode) {
this.statusCode = statusCode;
}
/**
* Whether to treat the redirect URL as a URI template.
* Set this flag to {@code false} if the redirect URL contains open
* and close curly braces "{", "}" and you don't want them interpreted
* as URI variables.
*
Defaults to {@code true}.
*/
public void setExpandUriTemplateVariables(boolean expandUriTemplateVariables) {
this.expandUriTemplateVariables = expandUriTemplateVariables;
}
/**
* 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}.
* @since 4.1
*/
public void setPropagateQueryParams(boolean propagateQueryParams) {
this.propagateQueryParams = propagateQueryParams;
}
/**
* Whether to propagate the query params of the current URL.
* @since 4.1
*/
public boolean isPropagateQueryProperties() {
return this.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 HttpServletResponse#encodeRedirectURL} for URLs that have a
* host and that host is not listed as a known host.
*
If not set (the default) all URLs are encoded through the response.
* @param hosts one or more application hosts
* @since 4.3
*/
public void setHosts(@Nullable String... hosts) {
this.hosts = hosts;
}
/**
* Return the configured application hosts.
* @since 4.3
*/
@Nullable
public String[] getHosts() {
return this.hosts;
}
/**
* Returns "true" indicating this view performs a redirect.
*/
@Override
public boolean isRedirectView() {
return true;
}
/**
* An ApplicationContext is not strictly required for RedirectView.
*/
@Override
protected boolean isContextRequired() {
return false;
}
/**
* Convert model to request parameters and redirect to the given URL.
* @see #appendQueryProperties
* @see #sendRedirect
*/
@Override
protected void renderMergedOutputModel(Map model, HttpServletRequest request,
HttpServletResponse response) throws IOException {
String targetUrl = createTargetUrl(model, request);
targetUrl = updateTargetUrl(targetUrl, model, request, response);
// Save flash attributes
RequestContextUtils.saveOutputFlashMap(targetUrl, request, response);
// Redirect
sendRedirect(request, response, targetUrl, this.http10Compatible);
}
/**
* 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, HttpServletRequest request)
throws UnsupportedEncodingException {
// Prepare target URL.
StringBuilder targetUrl = new StringBuilder();
String url = getUrl();
Assert.state(url != null, "'url' not set");
if (this.contextRelative && getUrl().startsWith("/")) {
// Do not apply context path to relative URLs.
targetUrl.append(getContextPath(request));
}
targetUrl.append(getUrl());
String enc = this.encodingScheme;
if (enc == null) {
enc = request.getCharacterEncoding();
}
if (enc == null) {
enc = WebUtils.DEFAULT_CHARACTER_ENCODING;
}
if (this.expandUriTemplateVariables && StringUtils.hasText(targetUrl)) {
Map variables = getCurrentRequestUriVariables(request);
targetUrl = replaceUriTemplateVariables(targetUrl.toString(), model, variables, enc);
}
if (isPropagateQueryProperties()) {
appendCurrentQueryParams(targetUrl, request);
}
if (this.exposeModelAttributes) {
appendQueryProperties(targetUrl, model, enc);
}
return targetUrl.toString();
}
private String getContextPath(HttpServletRequest request) {
String contextPath = request.getContextPath();
while (contextPath.startsWith("//")) {
contextPath = contextPath.substring(1);
}
return contextPath;
}
/**
* 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 a Map that contains model attributes
* @param currentUriVariables current request URI variables to use
* @param encodingScheme the encoding scheme to use
* @throws UnsupportedEncodingException if string encoding failed
*/
protected StringBuilder replaceUriTemplateVariables(
String targetUrl, Map model, Map currentUriVariables, String encodingScheme)
throws UnsupportedEncodingException {
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, endLastMatch, matcher.start());
result.append(UriUtils.encodePathSegment(value.toString(), encodingScheme));
endLastMatch = matcher.end();
}
result.append(targetUrl.substring(endLastMatch));
return result;
}
@SuppressWarnings("unchecked")
private Map getCurrentRequestUriVariables(HttpServletRequest request) {
String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE;
Map uriVars = (Map) request.getAttribute(name);
return (uriVars != null) ? uriVars : 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
* @since 4.1
*/
protected void appendCurrentQueryParams(StringBuilder targetUrl, HttpServletRequest request) {
String query = request.getQueryString();
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);
}
}
}
/**
* Append query properties to the redirect URL.
* Stringifies, URL-encodes and formats model attributes as query properties.
* @param targetUrl the StringBuilder to append the properties to
* @param model a Map that contains model attributes
* @param encodingScheme the encoding scheme to use
* @throws UnsupportedEncodingException if string encoding failed
* @see #queryProperties
*/
@SuppressWarnings("unchecked")
protected void appendQueryProperties(StringBuilder targetUrl, Map model, String encodingScheme)
throws UnsupportedEncodingException {
// 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 there aren't already some parameters, we need a "?".
boolean first = (targetUrl.toString().indexOf('?') < 0);
for (Map.Entry entry : queryProperties(model).entrySet()) {
Object rawValue = entry.getValue();
Collection values;
if (rawValue != null && rawValue.getClass().isArray()) {
values = CollectionUtils.arrayToList(rawValue);
}
else if (rawValue instanceof Collection collection) {
values = collection;
}
else {
values = Collections.singleton(rawValue);
}
for (Object value : values) {
if (first) {
targetUrl.append('?');
first = false;
}
else {
targetUrl.append('&');
}
String encodedKey = urlEncode(entry.getKey(), encodingScheme);
String encodedValue = (value != null ? urlEncode(value.toString(), encodingScheme) : "");
targetUrl.append(encodedKey).append('=').append(encodedValue);
}
}
// Append anchor fragment, if any, to end of URL.
if (fragment != null) {
targetUrl.append(fragment);
}
}
/**
* Determine name-value pairs for query strings, which will be stringified,
* URL-encoded and formatted by {@link #appendQueryProperties}.
* This implementation filters the model through checking
* {@link #isEligibleProperty(String, Object)} for each element,
* by default accepting Strings, primitives and primitive wrappers only.
* @param model the original model Map
* @return the filtered Map of eligible query properties
* @see #isEligibleProperty(String, Object)
*/
protected Map queryProperties(Map model) {
Map result = new LinkedHashMap<>();
model.forEach((name, value) -> {
if (isEligibleProperty(name, value)) {
result.put(name, value);
}
});
return result;
}
/**
* Determine whether the given model element should be exposed
* as a query property.
* The default implementation considers Strings and primitives
* as eligible, and also arrays and Collections/Iterables with
* corresponding elements. This can be overridden in subclasses.
* @param key the key of the model element
* @param value the value of the model element
* @return whether the element is eligible as query property
*/
protected boolean isEligibleProperty(String key, @Nullable Object value) {
if (value == null) {
return false;
}
if (isEligibleValue(value)) {
return true;
}
if (value.getClass().isArray()) {
int length = Array.getLength(value);
if (length == 0) {
return false;
}
for (int i = 0; i < length; i++) {
Object element = Array.get(value, i);
if (!isEligibleValue(element)) {
return false;
}
}
return true;
}
if (value instanceof Collection coll) {
if (coll.isEmpty()) {
return false;
}
for (Object element : coll) {
if (!isEligibleValue(element)) {
return false;
}
}
return true;
}
return false;
}
/**
* Determine whether the given model element value is eligible for exposure.
*
The default implementation considers primitives, strings, numbers, dates,
* URIs, URLs etc as eligible, according to {@link BeanUtils#isSimpleValueType}.
* This can be overridden in subclasses.
* @param value the model element value
* @return whether the element value is eligible
* @see BeanUtils#isSimpleValueType
*/
protected boolean isEligibleValue(@Nullable Object value) {
return (value != null && BeanUtils.isSimpleValueType(value.getClass()));
}
/**
* URL-encode the given input String with the given encoding scheme.
*
The default implementation uses {@code URLEncoder.encode(input, enc)}.
* @param input the unencoded input String
* @param encodingScheme the encoding scheme
* @return the encoded output String
* @throws UnsupportedEncodingException if thrown by the JDK URLEncoder
* @see java.net.URLEncoder#encode(String, String)
*/
protected String urlEncode(String input, String encodingScheme) throws UnsupportedEncodingException {
return URLEncoder.encode(input, encodingScheme);
}
/**
* Find the registered {@link RequestDataValueProcessor}, if any, and allow
* it to update the redirect target URL.
* @param targetUrl the given redirect URL
* @return the updated URL or the same as URL as the one passed in
*/
protected String updateTargetUrl(String targetUrl, Map model,
HttpServletRequest request, HttpServletResponse response) {
WebApplicationContext wac = getWebApplicationContext();
if (wac == null) {
wac = RequestContextUtils.findWebApplicationContext(request, getServletContext());
}
if (wac != null && wac.containsBean(RequestContextUtils.REQUEST_DATA_VALUE_PROCESSOR_BEAN_NAME)) {
RequestDataValueProcessor processor = wac.getBean(
RequestContextUtils.REQUEST_DATA_VALUE_PROCESSOR_BEAN_NAME, RequestDataValueProcessor.class);
return processor.processUrl(request, targetUrl);
}
return targetUrl;
}
/**
* Send a redirect back to the HTTP client.
* @param request current HTTP request (allows for reacting to request method)
* @param response current HTTP response (for sending response headers)
* @param targetUrl the target URL to redirect to
* @param http10Compatible whether to stay compatible with HTTP 1.0 clients
* @throws IOException if thrown by response methods
*/
protected void sendRedirect(HttpServletRequest request, HttpServletResponse response,
String targetUrl, boolean http10Compatible) throws IOException {
String encodedURL = (isRemoteHost(targetUrl) ? targetUrl : response.encodeRedirectURL(targetUrl));
if (http10Compatible) {
HttpStatusCode attributeStatusCode = (HttpStatusCode) request.getAttribute(View.RESPONSE_STATUS_ATTRIBUTE);
if (this.statusCode != null) {
response.setStatus(this.statusCode.value());
response.setHeader("Location", encodedURL);
}
else if (attributeStatusCode != null) {
response.setStatus(attributeStatusCode.value());
response.setHeader("Location", encodedURL);
}
else {
// Send status code 302 by default.
response.sendRedirect(encodedURL);
}
}
else {
HttpStatusCode statusCode = getHttp11StatusCode(request, response, targetUrl);
response.setStatus(statusCode.value());
response.setHeader("Location", encodedURL);
}
}
/**
* Whether the given targetUrl has a host that is a "foreign" system in which
* case {@link 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} if the target URL has a remote host, {@code false} if
* the URL does not have a host or the "host" property is not configured
* @since 4.3
*/
protected boolean isRemoteHost(String targetUrl) {
if (ObjectUtils.isEmpty(getHosts())) {
return false;
}
String targetHost = UriComponentsBuilder.fromUriString(targetUrl).build().getHost();
if (!StringUtils.hasLength(targetHost)) {
return false;
}
for (String host : getHosts()) {
if (targetHost.equals(host)) {
return false;
}
}
return true;
}
/**
* Determines the status code to use for HTTP 1.1 compatible requests.
*
The default implementation returns the {@link #setStatusCode(HttpStatusCode) statusCode}
* property if set, or the value of the {@link #RESPONSE_STATUS_ATTRIBUTE} attribute.
* If neither are set, it defaults to {@link HttpStatus#SEE_OTHER} (303).
* @param request the request to inspect
* @param response the servlet response
* @param targetUrl the target URL
* @return the response status
*/
protected HttpStatusCode getHttp11StatusCode(
HttpServletRequest request, HttpServletResponse response, String targetUrl) {
if (this.statusCode != null) {
return this.statusCode;
}
HttpStatusCode attributeStatusCode = (HttpStatusCode) request.getAttribute(View.RESPONSE_STATUS_ATTRIBUTE);
if (attributeStatusCode != null) {
return attributeStatusCode;
}
return HttpStatus.SEE_OTHER;
}
}