diff --git a/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java b/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java new file mode 100644 index 0000000000..e4ef11a1c9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java @@ -0,0 +1,441 @@ +/* + * Copyright 2002-2024 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.filter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.RequestPath; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; +import org.springframework.web.util.ServletRequestPathUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * {@code Filter} that can be configured to trim a trailing slashes, and either + * send a redirect or wrap the request and continue processing. + * + *

Use the static {@link #trimTrailingSlash(String...)} method to begin to + * configure and build an instance. For example: + * + *

+ * UrlHandlerFilter filter = UrlHandlerFilter
+ *    .trimTrailingSlash("/path1/**").andRedirect(HttpStatus.PERMANENT_REDIRECT)
+ *    .trimTrailingSlash("/path2/**").andHandleRequest()
+ *    .build();
+ * 
+ * + *

Note that this {@code Filter} should be ordered after + * {@link ForwardedHeaderFilter} and before the Spring Security filter chain. + * + * @author Rossen Stoyanchev + * @since 6.2 + */ +public final class UrlHandlerFilter extends OncePerRequestFilter { + + private static final Log logger = LogFactory.getLog(UrlHandlerFilter.class); + + + private final Map handlers; + + + private UrlHandlerFilter(Map handlers) { + this.handlers = new LinkedHashMap<>(handlers); + } + + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } + + @Override + protected boolean shouldNotFilterErrorDispatch() { + return false; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + RequestPath previousPath = (RequestPath) request.getAttribute(ServletRequestPathUtils.PATH_ATTRIBUTE); + RequestPath path = previousPath; + try { + if (path == null) { + path = ServletRequestPathUtils.parseAndCache(request); + } + for (Map.Entry entry : this.handlers.entrySet()) { + Handler handler = entry.getValue(); + if (entry.getKey().matches(path) && handler.shouldHandle(request)) { + handler.handle(request, response, chain); + return; + } + } + } + finally { + if (previousPath != null) { + ServletRequestPathUtils.setParsedRequestPath(previousPath, request); + } + } + + chain.doFilter(request, response); + } + + + /** + * Begin to configure and build a {@link UrlHandlerFilter} by adding a + * trailing slash handler for the specified paths. For more details, see + * {@link Builder#trimTrailingSlash(String...)}. + * @param pathPatterns the URL patterns to which trimming applies. + * The pattern itself does not need to end with a trailing slash. + * @return a spec to continue with configuring the handler + */ + public static TrailingSlashHandlerSpec trimTrailingSlash(String... pathPatterns) { + return new DefaultBuilder().trimTrailingSlash(pathPatterns); + } + + + /** + * Builder to configure and build a {@link UrlHandlerFilter}. + */ + public interface Builder { + + /** + * An entry point to configure a trim trailing slash handler. + * @param pathPatterns the URL patterns to which trimming applies. + * The pattern itself does not need to end with a trailing slash. + * @return a spec to continue with configuring the handler + */ + TrailingSlashHandlerSpec trimTrailingSlash(String... pathPatterns); + + /** + * Build the {@link UrlHandlerFilter} instance. + */ + UrlHandlerFilter build(); + } + + + /** + * A spec to configure a trailing slash handler. + */ + public interface TrailingSlashHandlerSpec { + + /** + * A callback to intercept requests with a trailing slash. + * @param consumer callback to be invoked for requests with a trailing slash + * @return the same spec instance + */ + TrailingSlashHandlerSpec intercept(Consumer consumer); + + /** + * Handle by sending a redirect with the given HTTP status and a location + * with the trailing slash trimmed. + * @param status the status to use + * @return to go back to the main {@link Builder} and either add more + * handlers or build the {@code Filter} instance. + */ + Builder andRedirect(HttpStatus status); + + /** + * Handle by wrapping the request with the trimmed trailing slash and + * delegating to the rest of the filter chain. + * @return to go back to the main {@link Builder} and either add more + * handlers or build the {@code Filter} instance. + */ + Builder andHandleRequest(); + } + + + /** + * Default {@link Builder} implementation. + */ + private static final class DefaultBuilder implements Builder { + + private final PathPatternParser patternParser = new PathPatternParser(); + + private final Map handlers = new LinkedHashMap<>(); + + @Override + public TrailingSlashHandlerSpec trimTrailingSlash(String... pathPatterns) { + return new DefaultTrailingSlashHandlerSpec(this, parseTrailingSlashPatterns(pathPatterns)); + } + + public void addHandler(List pathPatterns, Handler handler) { + for (PathPattern pattern : pathPatterns) { + this.handlers.put(pattern, handler); + } + } + + private List parseTrailingSlashPatterns(String... patternValues) { + List patterns = new ArrayList<>(patternValues.length); + for (String s : patternValues) { + if (!s.endsWith("**") && s.charAt(s.length() - 1) != '/') { + s += "/"; + } + patterns.add(this.patternParser.parse(s)); + } + return patterns; + } + + @Override + public UrlHandlerFilter build() { + return new UrlHandlerFilter(this.handlers); + } + + } + + + /** + * Default {@link TrailingSlashHandlerSpec} implementation. + */ + private static final class DefaultTrailingSlashHandlerSpec implements TrailingSlashHandlerSpec { + + private static final Predicate trailingSlashPredicate = + request -> request.getRequestURI().endsWith("/"); + + private static final Function trimTralingSlashFunction = path -> { + int index = (StringUtils.hasLength(path) ? path.lastIndexOf('/') : -1); + return (index != -1 ? path.substring(0, index) : path); + }; + + private final DefaultBuilder parent; + + private final List pathPatterns; + + @Nullable + private Consumer interceptors; + + private DefaultTrailingSlashHandlerSpec(DefaultBuilder parent, List pathPatterns) { + this.parent = parent; + this.pathPatterns = pathPatterns; + } + + @Override + public TrailingSlashHandlerSpec intercept(Consumer interceptor) { + this.interceptors = (this.interceptors != null ? this.interceptors.andThen(interceptor) : interceptor); + return this; + } + + @Override + public Builder andRedirect(HttpStatus status) { + return addHandler(new RedirectPathHandler( + trailingSlashPredicate, trimTralingSlashFunction, status, initInterceptor())); + } + + @Override + public Builder andHandleRequest() { + return addHandler(new RequestWrappingPathHandler( + trailingSlashPredicate, trimTralingSlashFunction, initInterceptor())); + } + + private Consumer initInterceptor() { + if (this.interceptors != null) { + return this.interceptors; + } + return request -> { + if (logger.isTraceEnabled()) { + logger.trace("Trimmed trailing slash: " + + request.getMethod() + " " + request.getRequestURI()); + } + }; + } + + private DefaultBuilder addHandler(Handler handler) { + this.parent.addHandler(this.pathPatterns, handler); + return this.parent; + } + } + + + /** + * Internal handler for {@link UrlHandlerFilter} to delegate to. + */ + private interface Handler { + + /** + * Whether the handler handles the given request. + */ + boolean shouldHandle(HttpServletRequest request); + + /** + * Handle the request, possibly delegating to the rest of the filter chain. + */ + void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException; + } + + + /** + * Base class for handlers that modify the URL path. + */ + private abstract static class AbstractPathHandler implements Handler { + + private final Predicate pathPredicate; + + private final Function pathFunction; + + private final Consumer interceptor; + + AbstractPathHandler( + Predicate pathPredicate, Function pathFunction, + Consumer interceptor) { + + this.pathPredicate = pathPredicate; + this.pathFunction = pathFunction; + this.interceptor = interceptor; + } + + protected Function getPathFunction() { + return this.pathFunction; + } + + @Override + public boolean shouldHandle(HttpServletRequest request) { + return this.pathPredicate.test(request); + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + this.interceptor.accept(request); + handleInternal(request, response, chain); + } + + protected abstract void handleInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException; + } + + + /** + * Path handler that sends a redirect. + */ + private static final class RedirectPathHandler extends AbstractPathHandler { + + private final HttpStatus httpStatus; + + RedirectPathHandler( + Predicate pathPredicate, Function pathFunction, + HttpStatus httpStatus, Consumer interceptor) { + + super(pathPredicate, pathFunction, interceptor); + this.httpStatus = httpStatus; + } + + @Override + public void handleInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException { + + String location = getPathFunction().apply(request.getRequestURI()); + + response.resetBuffer(); + response.setStatus(this.httpStatus.value()); + response.setHeader(HttpHeaders.LOCATION, location); + response.flushBuffer(); + } + } + + + /** + * Path handler that wraps the request and continues processing. + */ + private static final class RequestWrappingPathHandler extends AbstractPathHandler { + + RequestWrappingPathHandler( + Predicate pathPredicate, Function pathFunction, + Consumer interceptor) { + + super(pathPredicate, pathFunction, interceptor); + } + + @Override + public void handleInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + request = new PathHttpServletRequestWrapper(request, getPathFunction()); + chain.doFilter(request, response); + } + } + + + /** + * Wraps the request to return modified path information. + */ + private static class PathHttpServletRequestWrapper extends HttpServletRequestWrapper { + + private final String requestURI; + + private final StringBuffer requestURL; + + private final String servletPath; + + private final String pathInfo; + + PathHttpServletRequestWrapper(HttpServletRequest request, Function pathFunction) { + super(request); + this.requestURI = pathFunction.apply(request.getRequestURI()); + this.requestURL = new StringBuffer(pathFunction.apply(request.getRequestURL().toString())); + if (StringUtils.hasText(request.getPathInfo())) { + this.servletPath = request.getServletPath(); + this.pathInfo = pathFunction.apply(request.getPathInfo()); + } + else { + this.servletPath = pathFunction.apply(request.getServletPath()); + this.pathInfo = request.getPathInfo(); + } + } + + @Override + public String getRequestURI() { + return this.requestURI; + } + + @Override + public StringBuffer getRequestURL() { + return this.requestURL; + } + + @Override + public String getServletPath() { + return this.servletPath; + } + + @Override + public String getPathInfo() { + return this.pathInfo; + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java new file mode 100644 index 0000000000..7ecebd930d --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2024 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.filter; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; +import org.springframework.web.testfixture.servlet.MockFilterChain; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link UrlHandlerFilter}. + * + * @author Rossen Stoyanchev + */ +public class UrlHandlerFilterTests { + + @Test + void trimTrailingSlashAndHandle() throws Exception { + testTrimTrailingSlashAndHandle("/path/**", "/path/123", null); + testTrimTrailingSlashAndHandle("/path/*", "/path", "/123"); + testTrimTrailingSlashAndHandle("/path/*", "", "/path/123"); + } + + void testTrimTrailingSlashAndHandle( + String pattern, String servletPath, @Nullable String pathInfo) throws Exception { + + UrlHandlerFilter filter = UrlHandlerFilter.trimTrailingSlash(pattern).andHandleRequest().build(); + + boolean hasPathInfo = StringUtils.hasLength(pathInfo); + String requestURI = servletPath + (hasPathInfo ? pathInfo : ""); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestURI + "/"); + request.setServletPath(hasPathInfo ? servletPath : servletPath + "/"); + request.setPathInfo(hasPathInfo ? pathInfo + "/" : pathInfo); + + MockFilterChain chain = new MockFilterChain(); + filter.doFilterInternal(request, new MockHttpServletResponse(), chain); + + HttpServletRequest actual = (HttpServletRequest) chain.getRequest(); + assertThat(actual).isNotNull().isNotSameAs(request); + assertThat(actual.getRequestURI()).isEqualTo(requestURI); + assertThat(actual.getRequestURL().toString()).isEqualTo("http://localhost" + requestURI); + assertThat(actual.getServletPath()).isEqualTo(servletPath); + assertThat(actual.getPathInfo()).isEqualTo(pathInfo); + } + + @Test + void noTrailingSlashNoHandling() throws Exception { + testNoTrailingSlashNoHandling("/path/**", "/path/123"); + testNoTrailingSlashNoHandling("/path/*", "/path/123"); + } + + private static void testNoTrailingSlashNoHandling( + String pattern, String requestURI) throws ServletException, IOException { + + UrlHandlerFilter filter = UrlHandlerFilter.trimTrailingSlash(pattern).andHandleRequest().build(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestURI); + MockFilterChain chain = new MockFilterChain(); + filter.doFilterInternal(request, new MockHttpServletResponse(), chain); + + HttpServletRequest actual = (HttpServletRequest) chain.getRequest(); + assertThat(actual).as("Request should not be wrapped").isSameAs(request); + } + + @Test + void trimTrailingSlashAndRedirect() throws Exception { + HttpStatus status = HttpStatus.PERMANENT_REDIRECT; + UrlHandlerFilter filter = UrlHandlerFilter.trimTrailingSlash("/path/*").andRedirect(status).build(); + + String path = "/path/123"; + MockHttpServletResponse response = new MockHttpServletResponse(); + + MockFilterChain chain = new MockFilterChain(); + filter.doFilterInternal(new MockHttpServletRequest("GET", path + "/"), response, chain); + + assertThat(chain.getRequest()).isNull(); + assertThat(response.getStatus()).isEqualTo(status.value()); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo(path); + assertThat(response.isCommitted()).isTrue(); + } + + @Test + void noTrailingSlashNoRedirect() throws Exception { + HttpStatus status = HttpStatus.PERMANENT_REDIRECT; + UrlHandlerFilter filter = UrlHandlerFilter.trimTrailingSlash("/path/*").andRedirect(status).build(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path/123"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + MockFilterChain chain = new MockFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeader(HttpHeaders.LOCATION)).isNull(); + assertThat(response.isCommitted()).isFalse(); + } + +}