Browse Source
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-14913pull/1252/merge
14 changed files with 671 additions and 48 deletions
@ -0,0 +1,159 @@
@@ -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<ServerRequest, Optional<Resource>> { |
||||
|
||||
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<Resource> 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; |
||||
} |
||||
|
||||
|
||||
} |
||||
@ -0,0 +1,136 @@
@@ -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<Resource> { |
||||
|
||||
|
||||
private static final Set<HttpMethod> SUPPORTED_METHODS = |
||||
EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); |
||||
|
||||
|
||||
private final Resource resource; |
||||
|
||||
public ResourceHandlerFunction(Resource resource) { |
||||
this.resource = resource; |
||||
} |
||||
|
||||
@Override |
||||
public ServerResponse<Resource> 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(); |
||||
} |
||||
|
||||
} |
||||
|
||||
|
||||
} |
||||
@ -0,0 +1,78 @@
@@ -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<Void> request = MockServerRequest.builder() |
||||
.uri(new URI("http://localhost/resources/response.txt")) |
||||
.build(); |
||||
Optional<Resource> 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<Void> request = MockServerRequest.builder() |
||||
.uri(new URI("http://localhost/resources/child/response.txt")) |
||||
.build(); |
||||
Optional<Resource> 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<Void> request = MockServerRequest.builder() |
||||
.uri(new URI("http://localhost/resources/foo")) |
||||
.build(); |
||||
Optional<Resource> result = function.apply(request); |
||||
assertFalse(result.isPresent()); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,151 @@
@@ -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<Resource> response = this.handlerFunction.handle(request); |
||||
assertEquals(HttpStatus.OK, response.statusCode()); |
||||
assertEquals(this.resource, response.body()); |
||||
|
||||
Mono<Void> 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<Resource> response = this.handlerFunction.handle(request); |
||||
assertEquals(HttpStatus.OK, response.statusCode()); |
||||
|
||||
Mono<Void> 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<Resource> 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<Void> 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()); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
Hello World |
||||
This is a sample response text file. |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
Hello World |
||||
This is a sample response text file. |
||||
Loading…
Reference in new issue