From 136b33bc4a7ae4bf43730e9b17f35c30be30058e Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Thu, 1 Dec 2016 11:40:17 +0100 Subject: [PATCH] Allow serving static files from RouterFunctions This commit adds the ability to serve Resources (static files) through a RouterFunction. Two methods have been added to RouterFunctions: one that exposes a given directory given a path pattern, and a generic method that requires a lookup function. Issue: SPR-14913 --- .../DefaultServerResponseBuilder.java | 31 ++-- .../function/HandlerFilterFunction.java | 29 ++++ .../function/PathResourceLookupFunction.java | 159 ++++++++++++++++++ .../function/ResourceHandlerFunction.java | 136 +++++++++++++++ .../reactive/function/RouterFunctions.java | 65 +++++-- .../web/reactive/function/ServerResponse.java | 22 +-- .../DefaultServerResponseBuilderTests.java | 8 +- .../PathResourceLookupFunctionTests.java | 78 +++++++++ .../ResourceHandlerFunctionTests.java | 151 +++++++++++++++++ .../web/reactive/function/child/response.txt | 2 + .../web/reactive/function/response.txt | 2 + .../http/codec/BodyInserter.java | 1 + .../http/codec/BodyInserters.java | 10 ++ .../springframework/web/util/UriUtils.java | 25 ++- 14 files changed, 671 insertions(+), 48 deletions(-) create mode 100644 spring-web-reactive/src/main/java/org/springframework/web/reactive/function/PathResourceLookupFunction.java create mode 100644 spring-web-reactive/src/main/java/org/springframework/web/reactive/function/ResourceHandlerFunction.java create mode 100644 spring-web-reactive/src/test/java/org/springframework/web/reactive/function/PathResourceLookupFunctionTests.java create mode 100644 spring-web-reactive/src/test/java/org/springframework/web/reactive/function/ResourceHandlerFunctionTests.java create mode 100644 spring-web-reactive/src/test/resources/org/springframework/web/reactive/function/child/response.txt create mode 100644 spring-web-reactive/src/test/resources/org/springframework/web/reactive/function/response.txt diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/DefaultServerResponseBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/DefaultServerResponseBuilder.java index f7548e6b43c..5a240842022 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/DefaultServerResponseBuilder.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/DefaultServerResponseBuilder.java @@ -25,6 +25,7 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -55,12 +56,12 @@ import org.springframework.web.server.ServerWebExchange; */ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { - private final int statusCode; + private final HttpStatus statusCode; private final HttpHeaders headers = new HttpHeaders(); - public DefaultServerResponseBuilder(int statusCode) { + public DefaultServerResponseBuilder(HttpStatus statusCode) { this.statusCode = statusCode; } @@ -87,6 +88,12 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { return this; } + @Override + public ServerResponse.BodyBuilder allow(Set allowedMethods) { + this.headers.setAllow(allowedMethods); + return this; + } + @Override public ServerResponse.BodyBuilder contentLength(long contentLength) { this.headers.setContentLength(contentLength); @@ -144,9 +151,7 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { @Override public ServerResponse build() { - return body(BodyInserter.of( - (response, context) -> response.setComplete(), - () -> null)); + return body(BodyInserters.empty()); } @Override @@ -194,20 +199,20 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { } - private static abstract class AbstractServerResponse implements ServerResponse { + static abstract class AbstractServerResponse implements ServerResponse { - private final int statusCode; + private final HttpStatus statusCode; private final HttpHeaders headers; - protected AbstractServerResponse(int statusCode, HttpHeaders headers) { + protected AbstractServerResponse(HttpStatus statusCode, HttpHeaders headers) { this.statusCode = statusCode; this.headers = HttpHeaders.readOnlyHttpHeaders(headers); } @Override public final HttpStatus statusCode() { - return HttpStatus.valueOf(this.statusCode); + return this.statusCode; } @Override @@ -216,7 +221,7 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { } protected void writeStatusAndHeaders(ServerHttpResponse response) { - response.setStatusCode(HttpStatus.valueOf(this.statusCode)); + response.setStatusCode(this.statusCode); HttpHeaders responseHeaders = response.getHeaders(); if (!this.headers.isEmpty()) { @@ -233,7 +238,7 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { private final BodyInserter inserter; - public BodyInserterServerResponse(int statusCode, HttpHeaders headers, + public BodyInserterServerResponse(HttpStatus statusCode, HttpHeaders headers, BodyInserter inserter) { super(statusCode, headers); @@ -267,7 +272,9 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { private final Rendering rendering; - public RenderingServerResponse(int statusCode, HttpHeaders headers, String name, Map model) { + public RenderingServerResponse(HttpStatus statusCode, HttpHeaders headers, String name, + Map model) { + super(statusCode, headers); this.name = name; this.model = model; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/HandlerFilterFunction.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/HandlerFilterFunction.java index a9099e15726..7e17b96abf3 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/HandlerFilterFunction.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/HandlerFilterFunction.java @@ -16,6 +16,8 @@ package org.springframework.web.reactive.function; +import java.util.function.Function; + import org.springframework.util.Assert; import org.springframework.web.reactive.function.support.ServerRequestWrapper; @@ -70,4 +72,31 @@ public interface HandlerFilterFunction { return request -> this.filter(request, handler); } + /** + * Adapt the given request processor function to a filter function that only operates on the + * {@code ClientRequest}. + * @param requestProcessor the request processor + * @return the filter adaptation of the request processor + */ + static HandlerFilterFunction ofRequestProcessor(Function requestProcessor) { + + Assert.notNull(requestProcessor, "'requestProcessor' must not be null"); + return (request, next) -> next.handle(requestProcessor.apply(request)); + } + + /** + * Adapt the given response processor function to a filter function that only operates on the + * {@code ClientResponse}. + * @param responseProcessor the response processor + * @return the filter adaptation of the request processor + */ + static HandlerFilterFunction ofResponseProcessor(Function, + ServerResponse> responseProcessor) { + + Assert.notNull(responseProcessor, "'responseProcessor' must not be null"); + return (request, next) -> responseProcessor.apply(next.handle(request)); + } + + } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/PathResourceLookupFunction.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/PathResourceLookupFunction.java new file mode 100644 index 00000000000..cef07a83475 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/PathResourceLookupFunction.java @@ -0,0 +1,159 @@ +/* + * 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.function; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.function.Function; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriUtils; + +/** + * Lookup function used by {@link RouterFunctions#resources(String, Resource)}. + * + * @author Arjen Poutsma + * @since 5.0 + */ +class PathResourceLookupFunction implements Function> { + + private static final PathMatcher PATH_MATCHER = new AntPathMatcher(); + + private final String pattern; + + private final Resource location; + + public PathResourceLookupFunction(String pattern, Resource location) { + this.pattern = pattern; + this.location = location; + } + + @Override + public Optional apply(ServerRequest request) { + String path = processPath(request.path()); + if (path.contains("%")) { + path = UriUtils.decode(path, StandardCharsets.UTF_8); + } + if (!StringUtils.hasLength(path) || isInvalidPath(path)) { + return Optional.empty(); + } + if (!PATH_MATCHER.match(this.pattern, path)) { + return Optional.empty(); + } + else { + path = PATH_MATCHER.extractPathWithinPattern(this.pattern, path); + } + try { + Resource resource = this.location.createRelative(path); + if (resource.exists() && resource.isReadable() && isResourceUnderLocation(resource)) { + return Optional.of(resource); + } + else { + return Optional.empty(); + } + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static String processPath(String path) { + boolean slash = false; + for (int i = 0; i < path.length(); i++) { + if (path.charAt(i) == '/') { + slash = true; + } + else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { + if (i == 0 || (i == 1 && slash)) { + return path; + } + path = slash ? "/" + path.substring(i) : path.substring(i); + return path; + } + } + return (slash ? "/" : ""); + } + + private static boolean isInvalidPath(String path) { + if (path.contains("WEB-INF") || path.contains("META-INF")) { + return true; + } + if (path.contains(":/")) { + String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); + if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { + return true; + } + } + if (path.contains("..")) { + path = StringUtils.cleanPath(path); + if (path.contains("../")) { + return true; + } + } + return false; + } + + private boolean isResourceUnderLocation(Resource resource) throws + IOException { + if (resource.getClass() != this.location.getClass()) { + return false; + } + + String resourcePath; + String locationPath; + + if (resource instanceof UrlResource) { + resourcePath = resource.getURL().toExternalForm(); + locationPath = StringUtils.cleanPath(this.location.getURL().toString()); + } + else if (resource instanceof ClassPathResource) { + resourcePath = ((ClassPathResource) resource).getPath(); + locationPath = StringUtils.cleanPath(((ClassPathResource) this.location).getPath()); + } + else { + resourcePath = resource.getURL().getPath(); + locationPath = StringUtils.cleanPath(this.location.getURL().getPath()); + } + + if (locationPath.equals(resourcePath)) { + return true; + } + locationPath = (locationPath.endsWith("/") || locationPath.isEmpty() ? locationPath : + locationPath + "/"); + if (!resourcePath.startsWith(locationPath)) { + return false; + } + + if (resourcePath.contains("%")) { + if (UriUtils.decode(resourcePath, "UTF-8").contains("../")) { + return false; + } + } + + return true; + } + + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/ResourceHandlerFunction.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/ResourceHandlerFunction.java new file mode 100644 index 00000000000..c7a2964660a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/ResourceHandlerFunction.java @@ -0,0 +1,136 @@ +/* + * 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.function; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.EnumSet; +import java.util.Set; + +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.codec.BodyInserters; + +/** + * @author Arjen Poutsma + * @since 5.0 + */ +class ResourceHandlerFunction implements HandlerFunction { + + + private static final Set SUPPORTED_METHODS = + EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + + + private final Resource resource; + + public ResourceHandlerFunction(Resource resource) { + this.resource = resource; + } + + @Override + public ServerResponse handle(ServerRequest request) { + switch (request.method()) { + case GET: + return ServerResponse.ok() + .body(BodyInserters.fromResource(this.resource)); + case HEAD: + Resource headResource = new HeadMethodResource(this.resource); + return ServerResponse.ok() + .body(BodyInserters.fromResource(headResource)); + case OPTIONS: + return ServerResponse.ok() + .allow(SUPPORTED_METHODS) + .body(BodyInserters.empty()); + default: + return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED) + .allow(SUPPORTED_METHODS) + .body(BodyInserters.empty()); + } + } + + private static class HeadMethodResource implements Resource { + + private static final byte[] EMPTY = new byte[0]; + + private final Resource delegate; + + public HeadMethodResource(Resource delegate) { + this.delegate = delegate; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(EMPTY); + } + + // delegation + + @Override + public boolean exists() { + return this.delegate.exists(); + } + + @Override + public URL getURL() throws IOException { + return this.delegate.getURL(); + } + + @Override + public URI getURI() throws IOException { + return this.delegate.getURI(); + } + + @Override + public File getFile() throws IOException { + return this.delegate.getFile(); + } + + @Override + public long contentLength() throws IOException { + return this.delegate.contentLength(); + } + + @Override + public long lastModified() throws IOException { + return this.delegate.lastModified(); + } + + @Override + public Resource createRelative(String relativePath) throws IOException { + return this.delegate.createRelative(relativePath); + } + + @Override + public String getFilename() { + return this.delegate.getFilename(); + } + + @Override + public String getDescription() { + return this.delegate.getDescription(); + } + + } + + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/RouterFunctions.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/RouterFunctions.java index 0fbed629473..f699314d6f6 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/RouterFunctions.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/RouterFunctions.java @@ -18,9 +18,11 @@ package org.springframework.web.reactive.function; import java.util.Map; import java.util.Optional; +import java.util.function.Function; import reactor.core.publisher.Mono; +import org.springframework.core.io.Resource; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.util.Assert; import org.springframework.web.reactive.HandlerMapping; @@ -67,7 +69,7 @@ public abstract class RouterFunctions { * @param predicate the predicate to test * @param handlerFunction the handler function to route to * @param the type of the handler function - * @return a routing function that routes to {@code handlerFunction} if + * @return a router function that routes to {@code handlerFunction} if * {@code predicate} evaluates to {@code true} * @see RequestPredicates */ @@ -79,11 +81,11 @@ public abstract class RouterFunctions { } /** - * Route to the given routing function if the given request predicate applies. + * Route to the given router function if the given request predicate applies. * @param predicate the predicate to test - * @param routerFunction the routing function to route to + * @param routerFunction the router function to route to * @param the type of the handler function - * @return a routing function that routes to {@code routerFunction} if + * @return a router function that routes to {@code routerFunction} if * {@code predicate} evaluates to {@code true} * @see RequestPredicates */ @@ -103,7 +105,40 @@ public abstract class RouterFunctions { } /** - * Convert the given {@linkplain RouterFunction routing function} into a {@link HttpHandler}. + * Route requests that match the given pattern to resources relative to the given root location. + * For instance + *
+	 * Resource location = new FileSystemResource("public-resources/");
+	 * RoutingFunction<Resource> resources = RouterFunctions.resources("/resources/**", location);
+     * 
+ * @param pattern the pattern to match + * @param location the location directory relative to which resources should be resolved + * @return a router function that routes to resources + */ + public static RouterFunction resources(String pattern, Resource location) { + Assert.hasLength(pattern, "'pattern' must not be empty"); + Assert.notNull(location, "'location' must not be null"); + + return resources(new PathResourceLookupFunction(pattern, location)); + } + + /** + * Route to resources using the provided lookup function. If the lookup function provides a + * {@link Resource} for the given request, it will be it will be exposed using a + * {@link HandlerFunction} that handles GET, HEAD, and OPTIONS requests. + * @param lookupFunction the function to provide a {@link Resource} given the {@link ServerRequest} + * @return a router function that routes to resources + */ + public static RouterFunction resources(Function> lookupFunction) { + Assert.notNull(lookupFunction, "'lookupFunction' must not be null"); + + // TODO: make lookupFunction return Mono once SPR-14870 is resolved + return request -> lookupFunction.apply(request).map(ResourceHandlerFunction::new); + + } + + /** + * Convert the given {@linkplain RouterFunction router function} into a {@link HttpHandler}. * This conversion uses {@linkplain HandlerStrategies#builder() default strategies}. *

The returned {@code HttpHandler} can be adapted to run in *

    @@ -116,15 +151,15 @@ public abstract class RouterFunctions { *
  • Undertow using the * {@link org.springframework.http.server.reactive.UndertowHttpHandlerAdapter}.
  • *
- * @param routerFunction the routing function to convert - * @return an http handler that handles HTTP request using the given routing function + * @param routerFunction the router function to convert + * @return an http handler that handles HTTP request using the given router function */ public static HttpHandler toHttpHandler(RouterFunction routerFunction) { return toHttpHandler(routerFunction, HandlerStrategies.withDefaults()); } /** - * Convert the given {@linkplain RouterFunction routing function} into a {@link HttpHandler}, + * Convert the given {@linkplain RouterFunction router function} into a {@link HttpHandler}, * using the given strategies. *

The returned {@code HttpHandler} can be adapted to run in *

    @@ -137,9 +172,9 @@ public abstract class RouterFunctions { *
  • Undertow using the * {@link org.springframework.http.server.reactive.UndertowHttpHandlerAdapter}.
  • *
- * @param routerFunction the routing function to convert + * @param routerFunction the router function to convert * @param strategies the strategies to use - * @return an http handler that handles HTTP request using the given routing function + * @return an http handler that handles HTTP request using the given router function */ public static HttpHandler toHttpHandler(RouterFunction routerFunction, HandlerStrategies strategies) { Assert.notNull(routerFunction, "RouterFunction must not be null"); @@ -159,8 +194,8 @@ public abstract class RouterFunctions { * This conversion uses {@linkplain HandlerStrategies#builder() default strategies}. *

The returned {@code HandlerMapping} can be run in a * {@link org.springframework.web.reactive.DispatcherHandler}. - * @param routerFunction the routing function to convert - * @return an handler mapping that maps HTTP request to a handler using the given routing function + * @param routerFunction the router function to convert + * @return an handler mapping that maps HTTP request to a handler using the given router function * @see org.springframework.web.reactive.function.support.HandlerFunctionAdapter * @see org.springframework.web.reactive.function.support.ServerResponseResultHandler */ @@ -169,13 +204,13 @@ public abstract class RouterFunctions { } /** - * Convert the given {@linkplain RouterFunction routing function} into a {@link HandlerMapping}, + * Convert the given {@linkplain RouterFunction router function} into a {@link HandlerMapping}, * using the given strategies. *

The returned {@code HandlerMapping} can be run in a * {@link org.springframework.web.reactive.DispatcherHandler}. - * @param routerFunction the routing function to convert + * @param routerFunction the router function to convert * @param strategies the strategies to use - * @return an handler mapping that maps HTTP request to a handler using the given routing function + * @return an handler mapping that maps HTTP request to a handler using the given router function * @see org.springframework.web.reactive.function.support.HandlerFunctionAdapter * @see org.springframework.web.reactive.function.support.ServerResponseResultHandler */ diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/ServerResponse.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/ServerResponse.java index fb9b7219b92..7293ac98e04 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/ServerResponse.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/ServerResponse.java @@ -82,7 +82,7 @@ public interface ServerResponse { */ static BodyBuilder from(ServerResponse other) { Assert.notNull(other, "'other' must not be null"); - DefaultServerResponseBuilder builder = new DefaultServerResponseBuilder(other.statusCode().value()); + DefaultServerResponseBuilder builder = new DefaultServerResponseBuilder(other.statusCode()); return builder.headers(other.headers()); } @@ -94,16 +94,6 @@ public interface ServerResponse { */ static BodyBuilder status(HttpStatus status) { Assert.notNull(status, "HttpStatus must not be null"); - return new DefaultServerResponseBuilder(status.value()); - } - - /** - * Create a builder with the given status. - * - * @param status the response status - * @return the created builder - */ - static BodyBuilder status(int status) { return new DefaultServerResponseBuilder(status); } @@ -211,6 +201,16 @@ public interface ServerResponse { */ B allow(HttpMethod... allowedMethods); + /** + * Set the set of allowed {@link HttpMethod HTTP methods}, as specified + * by the {@code Allow} header. + * + * @param allowedMethods the allowed methods + * @return this builder + * @see HttpHeaders#setAllow(Set) + */ + B allow(Set allowedMethods); + /** * Set the entity tag of the body, as specified by the {@code ETag} header. * diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/DefaultServerResponseBuilderTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/DefaultServerResponseBuilderTests.java index 7f6241b4365..423b67ccb8b 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/DefaultServerResponseBuilderTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/DefaultServerResponseBuilderTests.java @@ -77,12 +77,6 @@ public class DefaultServerResponseBuilderTests { assertEquals(HttpStatus.CREATED, result.statusCode()); } - @Test - public void statusInt() throws Exception { - ServerResponse result = ServerResponse.status(201).build(); - assertEquals(HttpStatus.CREATED, result.statusCode()); - } - @Test public void ok() throws Exception { ServerResponse result = ServerResponse.ok().build(); @@ -186,7 +180,7 @@ public class DefaultServerResponseBuilderTests { @Test public void build() throws Exception { - ServerResponse result = ServerResponse.status(201).header("MyKey", "MyValue").build(); + ServerResponse result = ServerResponse.status(HttpStatus.CREATED).header("MyKey", "MyValue").build(); ServerWebExchange exchange = mock(ServerWebExchange.class); MockServerHttpResponse response = new MockServerHttpResponse(); diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/PathResourceLookupFunctionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/PathResourceLookupFunctionTests.java new file mode 100644 index 00000000000..f0e7c9db4b2 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/PathResourceLookupFunctionTests.java @@ -0,0 +1,78 @@ +/* + * 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.function; + +import java.net.URI; +import java.util.Optional; + +import org.junit.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author Arjen Poutsma + */ +public class PathResourceLookupFunctionTests { + + @Test + public void normal() throws Exception { + ClassPathResource location = new ClassPathResource("org/springframework/web/reactive/function/"); + + PathResourceLookupFunction function = new PathResourceLookupFunction("/resources/**", location); + MockServerRequest request = MockServerRequest.builder() + .uri(new URI("http://localhost/resources/response.txt")) + .build(); + Optional result = function.apply(request); + assertTrue(result.isPresent()); + + ClassPathResource expected = new ClassPathResource("response.txt", getClass()); + assertEquals(expected.getFile(), result.get().getFile()); + } + + @Test + public void subPath() throws Exception { + ClassPathResource location = new ClassPathResource("org/springframework/web/reactive/function/"); + + PathResourceLookupFunction function = new PathResourceLookupFunction("/resources/**", location); + MockServerRequest request = MockServerRequest.builder() + .uri(new URI("http://localhost/resources/child/response.txt")) + .build(); + Optional result = function.apply(request); + assertTrue(result.isPresent()); + + ClassPathResource expected = new ClassPathResource("org/springframework/web/reactive/function/child/response.txt"); + assertEquals(expected.getFile(), result.get().getFile()); + } + + @Test + public void notFound() throws Exception { + ClassPathResource location = new ClassPathResource("org/springframework/web/reactive/function/"); + + PathResourceLookupFunction function = new PathResourceLookupFunction("/resources/**", location); + MockServerRequest request = MockServerRequest.builder() + .uri(new URI("http://localhost/resources/foo")) + .build(); + Optional result = function.apply(request); + assertFalse(result.isPresent()); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/ResourceHandlerFunctionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/ResourceHandlerFunctionTests.java new file mode 100644 index 00000000000..53d0e33f735 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/ResourceHandlerFunctionTests.java @@ -0,0 +1,151 @@ +/* + * 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.function; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.EnumSet; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; +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.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * @author Arjen Poutsma + */ +public class ResourceHandlerFunctionTests { + + private Resource resource; + + private ResourceHandlerFunction handlerFunction; + + @Before + public void createResource() { + this.resource = new ClassPathResource("response.txt", getClass()); + this.handlerFunction = new ResourceHandlerFunction(this.resource); + } + + @Test + public void get() throws IOException { + MockServerHttpRequest mockRequest = + new MockServerHttpRequest(HttpMethod.GET, "http://localhost"); + MockServerHttpResponse mockResponse = new MockServerHttpResponse(); + ServerWebExchange exchange = new DefaultServerWebExchange(mockRequest, mockResponse, + new MockWebSessionManager()); + + ServerRequest request = new DefaultServerRequest(exchange, HandlerStrategies.withDefaults()); + + ServerResponse response = this.handlerFunction.handle(request); + assertEquals(HttpStatus.OK, response.statusCode()); + assertEquals(this.resource, response.body()); + + Mono result = response.writeTo(exchange, HandlerStrategies.withDefaults()); + + StepVerifier.create(result) + .expectComplete() + .verify(); + + StepVerifier.create(result).expectComplete().verify(); + + byte[] expectedBytes = Files.readAllBytes(this.resource.getFile().toPath()); + + StepVerifier.create(mockResponse.getBody()) + .consumeNextWith(dataBuffer -> { + byte[] resultBytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(resultBytes); + assertArrayEquals(expectedBytes, resultBytes); + }) + .expectComplete() + .verify(); + assertEquals(MediaType.TEXT_PLAIN, mockResponse.getHeaders().getContentType()); + assertEquals(49, mockResponse.getHeaders().getContentLength()); + } + + @Test + public void head() throws IOException { + MockServerHttpRequest mockRequest = + new MockServerHttpRequest(HttpMethod.HEAD, "http://localhost"); + MockServerHttpResponse mockResponse = new MockServerHttpResponse(); + ServerWebExchange exchange = new DefaultServerWebExchange(mockRequest, mockResponse, + new MockWebSessionManager()); + + ServerRequest request = new DefaultServerRequest(exchange, HandlerStrategies.withDefaults()); + + ServerResponse response = this.handlerFunction.handle(request); + assertEquals(HttpStatus.OK, response.statusCode()); + + Mono result = response.writeTo(exchange, HandlerStrategies.withDefaults()); + + StepVerifier.create(result) + .expectComplete() + .verify(); + + StepVerifier.create(result).expectComplete().verify(); + + StepVerifier.create(mockResponse.getBody()) + .expectComplete() + .verify(); + assertEquals(MediaType.TEXT_PLAIN, mockResponse.getHeaders().getContentType()); + assertEquals(49, mockResponse.getHeaders().getContentLength()); + } + + @Test + public void options() { + MockServerHttpRequest mockRequest = + new MockServerHttpRequest(HttpMethod.OPTIONS, "http://localhost"); + MockServerHttpResponse mockResponse = new MockServerHttpResponse(); + ServerWebExchange exchange = new DefaultServerWebExchange(mockRequest, mockResponse, + new MockWebSessionManager()); + + ServerRequest request = new DefaultServerRequest(exchange, HandlerStrategies.withDefaults()); + + ServerResponse response = this.handlerFunction.handle(request); + + assertEquals(HttpStatus.OK, response.statusCode()); + assertEquals(EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS), + response.headers().getAllow()); + assertNull(response.body()); + + Mono result = response.writeTo(exchange, HandlerStrategies.withDefaults()); + + StepVerifier.create(result) + .expectComplete() + .verify(); + assertEquals(HttpStatus.OK, mockResponse.getStatusCode()); + assertEquals(EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS), + mockResponse.getHeaders().getAllow()); + + assertNull(mockResponse.getBody()); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/resources/org/springframework/web/reactive/function/child/response.txt b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/function/child/response.txt new file mode 100644 index 00000000000..4888e525763 --- /dev/null +++ b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/function/child/response.txt @@ -0,0 +1,2 @@ +Hello World +This is a sample response text file. diff --git a/spring-web-reactive/src/test/resources/org/springframework/web/reactive/function/response.txt b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/function/response.txt new file mode 100644 index 00000000000..4888e525763 --- /dev/null +++ b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/function/response.txt @@ -0,0 +1,2 @@ +Hello World +This is a sample response text file. diff --git a/spring-web/src/main/java/org/springframework/http/codec/BodyInserter.java b/spring-web/src/main/java/org/springframework/http/codec/BodyInserter.java index e287e095064..20a34a4a340 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/BodyInserter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/BodyInserter.java @@ -30,6 +30,7 @@ import org.springframework.util.Assert; * * @author Arjen Poutsma * @since 5.0 + * @see BodyInserters */ public interface BodyInserter { diff --git a/spring-web/src/main/java/org/springframework/http/codec/BodyInserters.java b/spring-web/src/main/java/org/springframework/http/codec/BodyInserters.java index c59f954bfcf..7f962f96b60 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/BodyInserters.java +++ b/spring-web/src/main/java/org/springframework/http/codec/BodyInserters.java @@ -48,6 +48,16 @@ public abstract class BodyInserters { private static final ResolvableType SERVER_SIDE_EVENT_TYPE = ResolvableType.forClass(ServerSentEvent.class); + /** + * Return an empty {@code BodyInserter} that writes nothing. + * @return an empty {@code BodyInserter} + */ + public static BodyInserter empty() { + return BodyInserter.of( + (response, context) -> response.setComplete(), + () -> null); + } + /** * Return a {@code BodyInserter} that writes the given single object. * @param body the body of the response diff --git a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java index 6b2f3e50495..9a5acf546cb 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java @@ -18,6 +18,7 @@ package org.springframework.web.util; import java.io.ByteArrayOutputStream; import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; import org.springframework.util.Assert; @@ -183,8 +184,26 @@ public abstract class UriUtils { * @see java.net.URLDecoder#decode(String, String) */ public static String decode(String source, String encoding) throws UnsupportedEncodingException { - Assert.notNull(source, "Source must not be null"); - Assert.hasLength(encoding, "Encoding must not be empty"); + return decode(source, Charset.forName(encoding)); + } + + /** + * Decodes the given encoded source String into an URI. Based on the following rules: + *

    + *
  • Alphanumeric characters {@code "a"} through {@code "z"}, {@code "A"} through {@code "Z"}, and + * {@code "0"} through {@code "9"} stay the same.
  • + *
  • Special characters {@code "-"}, {@code "_"}, {@code "."}, and {@code "*"} stay the same.
  • + *
  • A sequence "{@code %xy}" is interpreted as a hexadecimal representation of the character.
  • + *
+ * @param source the source string + * @param charset the character set + * @return the decoded URI + * @throws IllegalArgumentException when the given source contains invalid encoded sequences + * @see java.net.URLDecoder#decode(String, String) + */ + public static String decode(String source, Charset charset) { + Assert.notNull(source, "'source' must not be null"); + Assert.notNull(charset, "'charset' must not be null"); int length = source.length(); ByteArrayOutputStream bos = new ByteArrayOutputStream(length); boolean changed = false; @@ -211,7 +230,7 @@ public abstract class UriUtils { bos.write(ch); } } - return (changed ? new String(bos.toByteArray(), encoding) : source); + return (changed ? new String(bos.toByteArray(), charset) : source); } /**