Skip to content

Commit 1ff2678

Browse files
committed
Add UrlHandlerFilter for WebFlux
Closes gh-32830
1 parent 5671744 commit 1ff2678

File tree

2 files changed

+440
-0
lines changed

2 files changed

+440
-0
lines changed
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.filter.reactive;
18+
19+
import java.util.ArrayList;
20+
import java.util.Arrays;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.function.Function;
24+
25+
import org.apache.commons.logging.Log;
26+
import org.apache.commons.logging.LogFactory;
27+
import reactor.core.publisher.Flux;
28+
import reactor.core.publisher.Mono;
29+
30+
import org.springframework.http.HttpHeaders;
31+
import org.springframework.http.HttpStatusCode;
32+
import org.springframework.http.server.PathContainer;
33+
import org.springframework.http.server.RequestPath;
34+
import org.springframework.http.server.reactive.ServerHttpRequest;
35+
import org.springframework.http.server.reactive.ServerHttpResponse;
36+
import org.springframework.lang.Nullable;
37+
import org.springframework.util.LinkedMultiValueMap;
38+
import org.springframework.util.MultiValueMap;
39+
import org.springframework.util.StringUtils;
40+
import org.springframework.web.server.ServerWebExchange;
41+
import org.springframework.web.server.WebFilter;
42+
import org.springframework.web.server.WebFilterChain;
43+
import org.springframework.web.util.pattern.PathPattern;
44+
import org.springframework.web.util.pattern.PathPatternParser;
45+
46+
/**
47+
* {@link org.springframework.web.server.WebFilter} that modifies the URL, and
48+
* then redirects or wraps the request to apply the change.
49+
*
50+
* <p>To create an instance, you can use the following:
51+
*
52+
* <pre>
53+
* UrlHandlerFilter filter = UrlHandlerFilter
54+
* .trailingSlashHandler("/path1/**").redirect(HttpStatus.PERMANENT_REDIRECT)
55+
* .trailingSlashHandler("/path2/**").mutateRequest()
56+
* .build();
57+
* </pre>
58+
*
59+
* <p>This {@code WebFilter} should be ordered ahead of security filters.
60+
*
61+
* @author Rossen Stoyanchev
62+
* @since 6.2
63+
*/
64+
public final class UrlHandlerFilter implements WebFilter {
65+
66+
private static final Log logger = LogFactory.getLog(UrlHandlerFilter.class);
67+
68+
69+
private final MultiValueMap<Handler, PathPattern> handlers;
70+
71+
72+
private UrlHandlerFilter(MultiValueMap<Handler, PathPattern> handlers) {
73+
this.handlers = new LinkedMultiValueMap<>(handlers);
74+
}
75+
76+
77+
@Override
78+
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
79+
RequestPath path = exchange.getRequest().getPath();
80+
for (Map.Entry<Handler, List<PathPattern>> entry : this.handlers.entrySet()) {
81+
if (!entry.getKey().canHandle(exchange)) {
82+
continue;
83+
}
84+
for (PathPattern pattern : entry.getValue()) {
85+
if (pattern.matches(path)) {
86+
return entry.getKey().handle(exchange, chain);
87+
}
88+
}
89+
}
90+
return chain.filter(exchange);
91+
}
92+
93+
/**
94+
* Create a builder by adding a handler for URL's with a trailing slash.
95+
* @param pathPatterns path patterns to map the handler to, e.g.
96+
* <code>"/path/&#42;"</code>, <code>"/path/&#42;&#42;"</code>,
97+
* <code>"/path/foo/"</code>.
98+
* @return a spec to configure the trailing slash handler with
99+
* @see Builder#trailingSlashHandler(String...)
100+
*/
101+
public static Builder.TrailingSlashSpec trailingSlashHandler(String... pathPatterns) {
102+
return new DefaultBuilder().trailingSlashHandler(pathPatterns);
103+
}
104+
105+
106+
/**
107+
* Builder for {@link UrlHandlerFilter}.
108+
*/
109+
public interface Builder {
110+
111+
/**
112+
* Add a handler for URL's with a trailing slash.
113+
* @param pathPatterns path patterns to map the handler to, e.g.
114+
* <code>"/path/&#42;"</code>, <code>"/path/&#42;&#42;"</code>,
115+
* <code>"/path/foo/"</code>.
116+
* @return a spec to configure the handler with
117+
*/
118+
TrailingSlashSpec trailingSlashHandler(String... pathPatterns);
119+
120+
/**
121+
* Build the {@link UrlHandlerFilter} instance.
122+
*/
123+
UrlHandlerFilter build();
124+
125+
126+
/**
127+
* A spec to configure a trailing slash handler.
128+
*/
129+
interface TrailingSlashSpec {
130+
131+
/**
132+
* Configure a request interceptor to be called just before the handler
133+
* is invoked when a URL with a trailing slash is matched.
134+
*/
135+
TrailingSlashSpec intercept(Function<ServerHttpRequest, Mono<Void>> interceptor);
136+
137+
/**
138+
* Handle requests by sending a redirect to the same URL but the
139+
* trailing slash trimmed.
140+
* @param statusCode the redirect status to use
141+
* @return the top level {@link Builder}, which allows adding more
142+
* handlers and then building the Filter instance.
143+
*/
144+
Builder redirect(HttpStatusCode statusCode);
145+
146+
/**
147+
* Handle the request by wrapping it in order to trim the trailing
148+
* slash, and delegating to the rest of the filter chain.
149+
* @return the top level {@link Builder}, which allows adding more
150+
* handlers and then building the Filter instance.
151+
*/
152+
Builder mutateRequest();
153+
}
154+
}
155+
156+
157+
/**
158+
* Default {@link Builder} implementation.
159+
*/
160+
private static final class DefaultBuilder implements Builder {
161+
162+
private final PathPatternParser patternParser = new PathPatternParser();
163+
164+
private final MultiValueMap<Handler, PathPattern> handlers = new LinkedMultiValueMap<>();
165+
166+
@Override
167+
public TrailingSlashSpec trailingSlashHandler(String... patterns) {
168+
return new DefaultTrailingSlashSpec(patterns);
169+
}
170+
171+
private DefaultBuilder addHandler(List<PathPattern> pathPatterns, Handler handler) {
172+
pathPatterns.forEach(pattern -> this.handlers.add(handler, pattern));
173+
return this;
174+
}
175+
176+
@Override
177+
public UrlHandlerFilter build() {
178+
return new UrlHandlerFilter(this.handlers);
179+
}
180+
181+
182+
private final class DefaultTrailingSlashSpec implements TrailingSlashSpec {
183+
184+
private final List<PathPattern> pathPatterns;
185+
186+
@Nullable
187+
private List<Function<ServerHttpRequest, Mono<Void>>> interceptors;
188+
189+
private DefaultTrailingSlashSpec(String[] patterns) {
190+
this.pathPatterns = Arrays.stream(patterns)
191+
.map(pattern -> pattern.endsWith("**") || pattern.endsWith("/") ? pattern : pattern + "/")
192+
.map(patternParser::parse)
193+
.toList();
194+
}
195+
196+
@Override
197+
public TrailingSlashSpec intercept(Function<ServerHttpRequest, Mono<Void>> interceptor) {
198+
this.interceptors = (this.interceptors != null ? this.interceptors : new ArrayList<>());
199+
this.interceptors.add(interceptor);
200+
return this;
201+
}
202+
203+
@Override
204+
public Builder redirect(HttpStatusCode statusCode) {
205+
Handler handler = new RedirectTrailingSlashHandler(statusCode, this.interceptors);
206+
return DefaultBuilder.this.addHandler(this.pathPatterns, handler);
207+
}
208+
209+
@Override
210+
public Builder mutateRequest() {
211+
Handler handler = new RequestWrappingTrailingSlashHandler(this.interceptors);
212+
return DefaultBuilder.this.addHandler(this.pathPatterns, handler);
213+
}
214+
}
215+
}
216+
217+
218+
/**
219+
* Internal handler to encapsulate different ways to handle a request.
220+
*/
221+
private interface Handler {
222+
223+
/**
224+
* Whether the handler handles the given request.
225+
*/
226+
boolean canHandle(ServerWebExchange exchange);
227+
228+
/**
229+
* Handle the request, possibly delegating to the rest of the filter chain.
230+
*/
231+
Mono<Void> handle(ServerWebExchange exchange, WebFilterChain chain);
232+
}
233+
234+
235+
/**
236+
* Base class for trailing slash {@link Handler} implementations.
237+
*/
238+
private abstract static class AbstractTrailingSlashHandler implements Handler {
239+
240+
private static final List<Function<ServerHttpRequest, Mono<Void>>> defaultInterceptors =
241+
List.of(request -> {
242+
if (logger.isTraceEnabled()) {
243+
logger.trace("Handling trailing slash URL: " + request.getMethod() + " " + request.getURI());
244+
}
245+
return Mono.empty();
246+
});
247+
248+
private final List<Function<ServerHttpRequest, Mono<Void>>> interceptors;
249+
250+
protected AbstractTrailingSlashHandler(@Nullable List<Function<ServerHttpRequest, Mono<Void>>> interceptors) {
251+
this.interceptors = (interceptors != null ? new ArrayList<>(interceptors) : defaultInterceptors);
252+
}
253+
254+
@Override
255+
public boolean canHandle(ServerWebExchange exchange) {
256+
List<PathContainer.Element> elements = exchange.getRequest().getPath().elements();
257+
return (elements.size() > 1 && elements.get(elements.size() - 1).value().equals("/"));
258+
}
259+
260+
@Override
261+
public Mono<Void> handle(ServerWebExchange exchange, WebFilterChain chain) {
262+
List<Mono<Void>> monos = new ArrayList<>(this.interceptors.size());
263+
this.interceptors.forEach(interceptor -> monos.add(interceptor.apply(exchange.getRequest())));
264+
return Flux.concat(monos).then(Mono.defer(() -> handleInternal(exchange, chain)));
265+
}
266+
267+
protected abstract Mono<Void> handleInternal(ServerWebExchange exchange, WebFilterChain chain);
268+
269+
protected String trimTrailingSlash(ServerHttpRequest request) {
270+
String path = request.getURI().getRawPath();
271+
int index = (StringUtils.hasLength(path) ? path.lastIndexOf('/') : -1);
272+
return (index != -1 ? path.substring(0, index) : path);
273+
}
274+
}
275+
276+
277+
/**
278+
* Path handler that sends a redirect.
279+
*/
280+
private static final class RedirectTrailingSlashHandler extends AbstractTrailingSlashHandler {
281+
282+
private final HttpStatusCode statusCode;
283+
284+
RedirectTrailingSlashHandler(
285+
HttpStatusCode statusCode, @Nullable List<Function<ServerHttpRequest, Mono<Void>>> interceptors) {
286+
287+
super(interceptors);
288+
this.statusCode = statusCode;
289+
}
290+
291+
@Override
292+
public Mono<Void> handleInternal(ServerWebExchange exchange, WebFilterChain chain) {
293+
ServerHttpResponse response = exchange.getResponse();
294+
response.setStatusCode(this.statusCode);
295+
response.getHeaders().set(HttpHeaders.LOCATION, trimTrailingSlash(exchange.getRequest()));
296+
return Mono.empty();
297+
}
298+
}
299+
300+
301+
/**
302+
* Path handler that mutates the request and continues processing.
303+
*/
304+
private static final class RequestWrappingTrailingSlashHandler extends AbstractTrailingSlashHandler {
305+
306+
RequestWrappingTrailingSlashHandler(@Nullable List<Function<ServerHttpRequest, Mono<Void>>> interceptors) {
307+
super(interceptors);
308+
}
309+
310+
@Override
311+
public Mono<Void> handleInternal(ServerWebExchange exchange, WebFilterChain chain) {
312+
ServerHttpRequest request = exchange.getRequest();
313+
ServerHttpRequest mutatedRequest = request.mutate().path(trimTrailingSlash(request)).build();
314+
return chain.filter(exchange.mutate().request(mutatedRequest).build());
315+
}
316+
}
317+
318+
}

0 commit comments

Comments
 (0)