diff --git a/build.gradle b/build.gradle index a4f1147a5c..6add490350 100644 --- a/build.gradle +++ b/build.gradle @@ -801,6 +801,7 @@ project("spring-web-reactive") { optional("com.fasterxml.jackson.core:jackson-databind:${jackson2Version}") optional("org.freemarker:freemarker:${freemarkerVersion}") optional "org.apache.httpcomponents:httpclient:${httpclientVersion}" + optional('org.webjars:webjars-locator:0.32') testCompile("javax.validation:validation-api:${beanvalVersion}") testCompile("org.apache.tomcat:tomcat-util:${tomcatVersion}") testCompile("org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}") @@ -822,6 +823,7 @@ project("spring-web-reactive") { testRuntime("javax.activation:activation:${activationApiVersion}") testRuntime("org.jboss.xnio:xnio-nio:${xnioVersion}") testRuntime("org.jboss.logging:jboss-logging:3.3.0.Final") + testRuntime("org.webjars:underscorejs:1.8.3") } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/AbstractMappingContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/AbstractMappingContentTypeResolver.java index b1f76a42c1..bd38fac63f 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/AbstractMappingContentTypeResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/AbstractMappingContentTypeResolver.java @@ -23,7 +23,6 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import org.springframework.http.MediaType; import org.springframework.util.LinkedMultiValueMap; @@ -44,7 +43,7 @@ import org.springframework.web.server.ServerWebExchange; public abstract class AbstractMappingContentTypeResolver implements MappingContentTypeResolver { /** Primary lookup for media types by key (e.g. "json" -> "application/json") */ - private final ConcurrentMap mediaTypeLookup = new ConcurrentHashMap<>(64); + private final Map mediaTypeLookup = new ConcurrentHashMap<>(64); /** Reverse lookup for keys associated with a media type */ private final MultiValueMap keyLookup = new LinkedMultiValueMap<>(64); @@ -65,6 +64,10 @@ public abstract class AbstractMappingContentTypeResolver implements MappingConte } + public Map getMediaTypes() { + return this.mediaTypeLookup; + } + /** * Sub-classes can use this method to look up a MediaType by key. * @param key the key converted to lower case @@ -77,7 +80,7 @@ public abstract class AbstractMappingContentTypeResolver implements MappingConte /** * Sub-classes can use this method get all mapped media types. */ - protected List getMediaTypes() { + protected List getAllMediaTypes() { return new ArrayList<>(this.mediaTypeLookup.values()); } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java index 294587d2d9..aa57177dd7 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java @@ -77,7 +77,7 @@ public class ParameterContentTypeResolver extends AbstractMappingContentTypeReso @Override protected MediaType handleNoMatch(String key) throws NotAcceptableStatusException { - throw new NotAcceptableStatusException(getMediaTypes()); + throw new NotAcceptableStatusException(getAllMediaTypes()); } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolver.java index a204f3312e..5a2d17a639 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolver.java @@ -97,7 +97,7 @@ public class PathExtensionContentTypeResolver extends AbstractMappingContentType } } if (!this.ignoreUnknownExtensions) { - throw new NotAcceptableStatusException(getMediaTypes()); + throw new NotAcceptableStatusException(getAllMediaTypes()); } return null; } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java index 6bfcd1ecbd..e58c928751 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java @@ -218,7 +218,7 @@ public class RequestedContentTypeResolverBuilder { } - public RequestedContentTypeResolver build() { + public CompositeContentTypeResolver build() { List resolvers = new ArrayList<>(); if (this.favorPathExtension) { diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AbstractResourceResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AbstractResourceResolver.java new file mode 100644 index 0000000000..c4bbf3406f --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AbstractResourceResolver.java @@ -0,0 +1,66 @@ +/* + * 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.resource; + +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.io.Resource; +import org.springframework.web.server.ServerWebExchange; + +/** + * Base {@link ResourceResolver} providing consistent logging. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public abstract class AbstractResourceResolver implements ResourceResolver { + + protected final Log logger = LogFactory.getLog(getClass()); + + + @Override + public Resource resolveResource(ServerWebExchange exchange, String requestPath, + List locations, ResourceResolverChain chain) { + + if (logger.isTraceEnabled()) { + logger.trace("Resolving resource for request path \"" + requestPath + "\""); + } + return resolveResourceInternal(exchange, requestPath, locations, chain); + } + + @Override + public String resolveUrlPath(String resourceUrlPath, List locations, + ResourceResolverChain chain) { + + if (logger.isTraceEnabled()) { + logger.trace("Resolving public URL for resource path \"" + resourceUrlPath + "\""); + } + + return resolveUrlPathInternal(resourceUrlPath, locations, chain); + } + + + protected abstract Resource resolveResourceInternal(ServerWebExchange exchange, String requestPath, + List locations, ResourceResolverChain chain); + + protected abstract String resolveUrlPathInternal(String resourceUrlPath, + List locations, ResourceResolverChain chain); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AbstractVersionStrategy.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AbstractVersionStrategy.java new file mode 100644 index 0000000000..c5cdb6449f --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AbstractVersionStrategy.java @@ -0,0 +1,147 @@ +/* + * 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.resource; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Abstract base class for {@link VersionStrategy} implementations. + * + *

Supports versions as: + *

    + *
  • prefix in the request path, like "version/static/myresource.js" + *
  • file name suffix in the request path, like "static/myresource-version.js" + *
+ * + *

Note: This base class does not provide support for generating the + * version string. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + */ +public abstract class AbstractVersionStrategy implements VersionStrategy { + + protected final Log logger = LogFactory.getLog(getClass()); + + private final VersionPathStrategy pathStrategy; + + + protected AbstractVersionStrategy(VersionPathStrategy pathStrategy) { + Assert.notNull(pathStrategy, "VersionPathStrategy is required"); + this.pathStrategy = pathStrategy; + } + + + public VersionPathStrategy getVersionPathStrategy() { + return this.pathStrategy; + } + + + @Override + public String extractVersion(String requestPath) { + return this.pathStrategy.extractVersion(requestPath); + } + + @Override + public String removeVersion(String requestPath, String version) { + return this.pathStrategy.removeVersion(requestPath, version); + } + + @Override + public String addVersion(String requestPath, String version) { + return this.pathStrategy.addVersion(requestPath, version); + } + + + /** + * A prefix-based {@code VersionPathStrategy}, + * e.g. {@code "{version}/path/foo.js"}. + */ + protected static class PrefixVersionPathStrategy implements VersionPathStrategy { + + private final String prefix; + + public PrefixVersionPathStrategy(String version) { + Assert.hasText(version, "'version' must not be empty"); + this.prefix = version; + } + + @Override + public String extractVersion(String requestPath) { + return (requestPath.startsWith(this.prefix) ? this.prefix : null); + } + + @Override + public String removeVersion(String requestPath, String version) { + return requestPath.substring(this.prefix.length()); + } + + @Override + public String addVersion(String path, String version) { + if (path.startsWith(".")) { + return path; + } + else { + return (this.prefix.endsWith("/") || path.startsWith("/") ? + this.prefix + path : this.prefix + "/" + path); + } + } + } + + + /** + * File name-based {@code VersionPathStrategy}, + * e.g. {@code "path/foo-{version}.css"}. + */ + protected static class FileNameVersionPathStrategy implements VersionPathStrategy { + + private static final Pattern pattern = Pattern.compile("-(\\S*)\\."); + + @Override + public String extractVersion(String requestPath) { + Matcher matcher = pattern.matcher(requestPath); + if (matcher.find()) { + String match = matcher.group(1); + return (match.contains("-") ? match.substring(match.lastIndexOf("-") + 1) : match); + } + else { + return null; + } + } + + @Override + public String removeVersion(String requestPath, String version) { + return StringUtils.delete(requestPath, "-" + version); + } + + @Override + public String addVersion(String requestPath, String version) { + String baseFilename = StringUtils.stripFilenameExtension(requestPath); + String extension = StringUtils.getFilenameExtension(requestPath); + return (baseFilename + "-" + version + "." + extension); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AppCacheManifestTransformer.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AppCacheManifestTransformer.java new file mode 100644 index 0000000000..6c8afe5ee1 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/AppCacheManifestTransformer.java @@ -0,0 +1,231 @@ +/* + * 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.resource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Scanner; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.io.Resource; +import org.springframework.util.DigestUtils; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link ResourceTransformer} implementation that helps handling resources + * within HTML5 AppCache manifests for HTML5 offline applications. + * + *

This transformer: + *

    + *
  • modifies links to match the public URL paths that should be exposed to + * clients, using configured {@code ResourceResolver} strategies + *
  • appends a comment in the manifest, containing a Hash + * (e.g. "# Hash: 9de0f09ed7caf84e885f1f0f11c7e326"), thus changing the content + * of the manifest in order to trigger an appcache reload in the browser. + *
+ * + * All files that have the ".manifest" file extension, or the extension given + * in the constructor, will be transformed by this class. + * + *

This hash is computed using the content of the appcache manifest and the + * content of the linked resources; so changing a resource linked in the manifest + * or the manifest itself should invalidate the browser cache. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + * @see HTML5 offline applications spec + */ +public class AppCacheManifestTransformer extends ResourceTransformerSupport { + + private static final String MANIFEST_HEADER = "CACHE MANIFEST"; + + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final Log logger = LogFactory.getLog(AppCacheManifestTransformer.class); + + + private final Map sectionTransformers = new HashMap<>(); + + private final String fileExtension; + + + /** + * Create an AppCacheResourceTransformer that transforms files with extension ".manifest". + */ + public AppCacheManifestTransformer() { + this("manifest"); + } + + /** + * Create an AppCacheResourceTransformer that transforms files with the extension + * given as a parameter. + */ + public AppCacheManifestTransformer(String fileExtension) { + this.fileExtension = fileExtension; + + SectionTransformer noOpSection = new NoOpSection(); + this.sectionTransformers.put(MANIFEST_HEADER, noOpSection); + this.sectionTransformers.put("NETWORK:", noOpSection); + this.sectionTransformers.put("FALLBACK:", noOpSection); + this.sectionTransformers.put("CACHE:", new CacheSection()); + } + + + @Override + public Resource transform(ServerWebExchange exchange, Resource resource, + ResourceTransformerChain transformerChain) throws IOException { + + resource = transformerChain.transform(exchange, resource); + if (!this.fileExtension.equals(StringUtils.getFilenameExtension(resource.getFilename()))) { + return resource; + } + + byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); + String content = new String(bytes, DEFAULT_CHARSET); + + if (!content.startsWith(MANIFEST_HEADER)) { + if (logger.isTraceEnabled()) { + logger.trace("AppCache manifest does not start with 'CACHE MANIFEST', skipping: " + resource); + } + return resource; + } + + if (logger.isTraceEnabled()) { + logger.trace("Transforming resource: " + resource); + } + + StringWriter contentWriter = new StringWriter(); + HashBuilder hashBuilder = new HashBuilder(content.length()); + + Scanner scanner = new Scanner(content); + SectionTransformer currentTransformer = this.sectionTransformers.get(MANIFEST_HEADER); + while (scanner.hasNextLine()) { + String line = scanner.nextLine(); + if (this.sectionTransformers.containsKey(line.trim())) { + currentTransformer = this.sectionTransformers.get(line.trim()); + contentWriter.write(line + "\n"); + hashBuilder.appendString(line); + } + else { + contentWriter.write( + currentTransformer.transform( + line, hashBuilder, resource, transformerChain, exchange) + "\n"); + } + } + + String hash = hashBuilder.build(); + contentWriter.write("\n" + "# Hash: " + hash); + if (logger.isTraceEnabled()) { + logger.trace("AppCache file: [" + resource.getFilename()+ "] hash: [" + hash + "]"); + } + + return new TransformedResource(resource, contentWriter.toString().getBytes(DEFAULT_CHARSET)); + } + + + @FunctionalInterface + private interface SectionTransformer { + + /** + * Transforms a line in a section of the manifest. + *

The actual transformation depends on the chosen transformation strategy + * for the current manifest section (CACHE, NETWORK, FALLBACK, etc). + */ + String transform(String line, HashBuilder builder, Resource resource, + ResourceTransformerChain transformerChain, ServerWebExchange exchange) throws IOException; + } + + + private static class NoOpSection implements SectionTransformer { + + public String transform(String line, HashBuilder builder, Resource resource, + ResourceTransformerChain transformerChain, ServerWebExchange exchange) throws IOException { + + builder.appendString(line); + return line; + } + } + + + private class CacheSection implements SectionTransformer { + + private static final String COMMENT_DIRECTIVE = "#"; + + @Override + public String transform(String line, HashBuilder builder, Resource resource, + ResourceTransformerChain transformerChain, ServerWebExchange exchange) throws IOException { + + if (isLink(line) && !hasScheme(line)) { + ResourceResolverChain resolverChain = transformerChain.getResolverChain(); + Resource appCacheResource = + resolverChain.resolveResource(null, line, Collections.singletonList(resource)); + String path = resolveUrlPath(line, exchange, resource, transformerChain); + builder.appendResource(appCacheResource); + if (logger.isTraceEnabled()) { + logger.trace("Link modified: " + path + " (original: " + line + ")"); + } + return path; + } + builder.appendString(line); + return line; + } + + private boolean hasScheme(String link) { + int schemeIndex = link.indexOf(":"); + return (link.startsWith("//") || (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/"))); + } + + private boolean isLink(String line) { + return (StringUtils.hasText(line) && !line.startsWith(COMMENT_DIRECTIVE)); + } + } + + + private static class HashBuilder { + + private final ByteArrayOutputStream baos; + + public HashBuilder(int initialSize) { + this.baos = new ByteArrayOutputStream(initialSize); + } + + public void appendResource(Resource resource) throws IOException { + byte[] content = FileCopyUtils.copyToByteArray(resource.getInputStream()); + this.baos.write(DigestUtils.md5Digest(content)); + } + + public void appendString(String content) throws IOException { + this.baos.write(content.getBytes(DEFAULT_CHARSET)); + } + + public String build() { + return DigestUtils.md5DigestAsHex(this.baos.toByteArray()); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CachingResourceResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CachingResourceResolver.java new file mode 100644 index 0000000000..a4994179c6 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CachingResourceResolver.java @@ -0,0 +1,125 @@ +/* + * 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.resource; + +import java.util.List; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link ResourceResolver} that resolves resources from a {@link Cache} or + * otherwise delegates to the resolver chain and caches the result. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + */ +public class CachingResourceResolver extends AbstractResourceResolver { + + public static final String RESOLVED_RESOURCE_CACHE_KEY_PREFIX = "resolvedResource:"; + + public static final String RESOLVED_URL_PATH_CACHE_KEY_PREFIX = "resolvedUrlPath:"; + + + private final Cache cache; + + + public CachingResourceResolver(CacheManager cacheManager, String cacheName) { + this(cacheManager.getCache(cacheName)); + } + + public CachingResourceResolver(Cache cache) { + Assert.notNull(cache, "Cache is required"); + this.cache = cache; + } + + + /** + * Return the configured {@code Cache}. + */ + public Cache getCache() { + return this.cache; + } + + + @Override + protected Resource resolveResourceInternal(ServerWebExchange exchange, String requestPath, + List locations, ResourceResolverChain chain) { + + String key = computeKey(exchange, requestPath); + Resource resource = this.cache.get(key, Resource.class); + + if (resource != null) { + if (logger.isTraceEnabled()) { + logger.trace("Found match: " + resource); + } + return resource; + } + + resource = chain.resolveResource(exchange, requestPath, locations); + if (resource != null) { + if (logger.isTraceEnabled()) { + logger.trace("Putting resolved resource in cache: " + resource); + } + this.cache.put(key, resource); + } + + return resource; + } + + protected String computeKey(ServerWebExchange exchange, String requestPath) { + StringBuilder key = new StringBuilder(RESOLVED_RESOURCE_CACHE_KEY_PREFIX); + key.append(requestPath); + if (exchange != null) { + String encoding = exchange.getRequest().getHeaders().getFirst("Accept-Encoding"); + if (encoding != null && encoding.contains("gzip")) { + key.append("+encoding=gzip"); + } + } + return key.toString(); + } + + @Override + protected String resolveUrlPathInternal(String resourceUrlPath, + List locations, ResourceResolverChain chain) { + + String key = RESOLVED_URL_PATH_CACHE_KEY_PREFIX + resourceUrlPath; + String resolvedUrlPath = this.cache.get(key, String.class); + + if (resolvedUrlPath != null) { + if (logger.isTraceEnabled()) { + logger.trace("Found match: \"" + resolvedUrlPath + "\""); + } + return resolvedUrlPath; + } + + resolvedUrlPath = chain.resolveUrlPath(resourceUrlPath, locations); + if (resolvedUrlPath != null) { + if (logger.isTraceEnabled()) { + logger.trace("Putting resolved resource URL path in cache: \"" + resolvedUrlPath + "\""); + } + this.cache.put(key, resolvedUrlPath); + } + + return resolvedUrlPath; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CachingResourceTransformer.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CachingResourceTransformer.java new file mode 100644 index 0000000000..c4a629801f --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CachingResourceTransformer.java @@ -0,0 +1,85 @@ +/* + * 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.resource; + +import java.io.IOException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link ResourceTransformer} that checks a {@link Cache} to see if a + * previously transformed resource exists in the cache and returns it if found, + * or otherwise delegates to the resolver chain and caches the result. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class CachingResourceTransformer implements ResourceTransformer { + + private static final Log logger = LogFactory.getLog(CachingResourceTransformer.class); + + private final Cache cache; + + + public CachingResourceTransformer(CacheManager cacheManager, String cacheName) { + this(cacheManager.getCache(cacheName)); + } + + public CachingResourceTransformer(Cache cache) { + Assert.notNull(cache, "Cache is required"); + this.cache = cache; + } + + + /** + * Return the configured {@code Cache}. + */ + public Cache getCache() { + return this.cache; + } + + + @Override + public Resource transform(ServerWebExchange exchange, Resource resource, + ResourceTransformerChain transformerChain) throws IOException { + + Resource transformed = this.cache.get(resource, Resource.class); + if (transformed != null) { + if (logger.isTraceEnabled()) { + logger.trace("Found match: " + transformed); + } + return transformed; + } + + transformed = transformerChain.transform(exchange, resource); + + if (logger.isTraceEnabled()) { + logger.trace("Putting transformed resource in cache: " + transformed); + } + this.cache.put(resource, transformed); + + return transformed; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ContentVersionStrategy.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ContentVersionStrategy.java new file mode 100644 index 0000000000..0a4356defb --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ContentVersionStrategy.java @@ -0,0 +1,54 @@ +/* + * 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.resource; + +import java.io.IOException; + +import org.springframework.core.io.Resource; +import org.springframework.util.DigestUtils; +import org.springframework.util.FileCopyUtils; + +/** + * A {@code VersionStrategy} that calculates an Hex MD5 hashes from the content + * of the resource and appends it to the file name, e.g. + * {@code "styles/main-e36d2e05253c6c7085a91522ce43a0b4.css"}. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + * @see VersionResourceResolver + */ +public class ContentVersionStrategy extends AbstractVersionStrategy { + + + public ContentVersionStrategy() { + super(new FileNameVersionPathStrategy()); + } + + + @Override + public String getResourceVersion(Resource resource) { + try { + byte[] content = FileCopyUtils.copyToByteArray(resource.getInputStream()); + return DigestUtils.md5DigestAsHex(content); + } + catch (IOException ex) { + throw new IllegalStateException("Failed to calculate hash for " + resource, ex); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java new file mode 100644 index 0000000000..2db80ac947 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java @@ -0,0 +1,270 @@ +/* + * 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.resource; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.io.Resource; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link ResourceTransformer} implementation that modifies links in a CSS + * file to match the public URL paths that should be exposed to clients (e.g. + * with an MD5 content-based hash inserted in the URL). + * + *

The implementation looks for links in CSS {@code @import} statements and + * also inside CSS {@code url()} functions. All links are then passed through the + * {@link ResourceResolverChain} and resolved relative to the location of the + * containing CSS file. If successfully resolved, the link is modified, otherwise + * the original link is preserved. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class CssLinkResourceTransformer extends ResourceTransformerSupport { + + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final Log logger = LogFactory.getLog(CssLinkResourceTransformer.class); + + private final List linkParsers = new ArrayList<>(2); + + + public CssLinkResourceTransformer() { + this.linkParsers.add(new ImportStatementCssLinkParser()); + this.linkParsers.add(new UrlFunctionCssLinkParser()); + } + + + @Override + public Resource transform(ServerWebExchange exchange, Resource resource, + ResourceTransformerChain transformerChain) throws IOException { + + resource = transformerChain.transform(exchange, resource); + + String filename = resource.getFilename(); + if (!"css".equals(StringUtils.getFilenameExtension(filename))) { + return resource; + } + + if (logger.isTraceEnabled()) { + logger.trace("Transforming resource: " + resource); + } + + byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream()); + String content = new String(bytes, DEFAULT_CHARSET); + + Set infos = new HashSet<>(8); + for (CssLinkParser parser : this.linkParsers) { + parser.parseLink(content, infos); + } + + if (infos.isEmpty()) { + if (logger.isTraceEnabled()) { + logger.trace("No links found."); + } + return resource; + } + + List sortedInfos = new ArrayList<>(infos); + Collections.sort(sortedInfos); + + int index = 0; + StringWriter writer = new StringWriter(); + for (CssLinkInfo info : sortedInfos) { + writer.write(content.substring(index, info.getStart())); + String link = content.substring(info.getStart(), info.getEnd()); + String newLink = null; + if (!hasScheme(link)) { + newLink = resolveUrlPath(link, exchange, resource, transformerChain); + } + if (logger.isTraceEnabled()) { + if (newLink != null && !link.equals(newLink)) { + logger.trace("Link modified: " + newLink + " (original: " + link + ")"); + } + else { + logger.trace("Link not modified: " + link); + } + } + writer.write(newLink != null ? newLink : link); + index = info.getEnd(); + } + writer.write(content.substring(index)); + + return new TransformedResource(resource, writer.toString().getBytes(DEFAULT_CHARSET)); + } + + private boolean hasScheme(String link) { + int schemeIndex = link.indexOf(":"); + return (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")) || link.indexOf("//") == 0; + } + + + @FunctionalInterface + protected interface CssLinkParser { + + void parseLink(String content, Set linkInfos); + } + + + protected static abstract class AbstractCssLinkParser implements CssLinkParser { + + /** + * Return the keyword to use to search for links. + */ + protected abstract String getKeyword(); + + @Override + public void parseLink(String content, Set linkInfos) { + int index = 0; + do { + index = content.indexOf(getKeyword(), index); + if (index == -1) { + break; + } + index = skipWhitespace(content, index + getKeyword().length()); + if (content.charAt(index) == '\'') { + index = addLink(index, "'", content, linkInfos); + } + else if (content.charAt(index) == '"') { + index = addLink(index, "\"", content, linkInfos); + } + else { + index = extractLink(index, content, linkInfos); + + } + } + while (true); + } + + private int skipWhitespace(String content, int index) { + while (true) { + if (Character.isWhitespace(content.charAt(index))) { + index++; + continue; + } + return index; + } + } + + protected int addLink(int index, String endKey, String content, Set linkInfos) { + int start = index + 1; + int end = content.indexOf(endKey, start); + linkInfos.add(new CssLinkInfo(start, end)); + return end + endKey.length(); + } + + /** + * Invoked after a keyword match, after whitespaces removed, and when + * the next char is neither a single nor double quote. + */ + protected abstract int extractLink(int index, String content, Set linkInfos); + + } + + + private static class ImportStatementCssLinkParser extends AbstractCssLinkParser { + + @Override + protected String getKeyword() { + return "@import"; + } + + @Override + protected int extractLink(int index, String content, Set linkInfos) { + if (content.substring(index, index + 4).equals("url(")) { + // Ignore, UrlLinkParser will take care + } + else if (logger.isErrorEnabled()) { + logger.error("Unexpected syntax for @import link at index " + index); + } + return index; + } + } + + + private static class UrlFunctionCssLinkParser extends AbstractCssLinkParser { + + @Override + protected String getKeyword() { + return "url("; + } + + @Override + protected int extractLink(int index, String content, Set linkInfos) { + // A url() function without unquoted + return addLink(index - 1, ")", content, linkInfos); + } + } + + + private static class CssLinkInfo implements Comparable { + + private final int start; + + private final int end; + + public CssLinkInfo(int start, int end) { + this.start = start; + this.end = end; + } + + public int getStart() { + return this.start; + } + + public int getEnd() { + return this.end; + } + + @Override + public int compareTo(CssLinkInfo other) { + return (this.start < other.start ? -1 : (this.start == other.start ? 0 : 1)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj != null && obj instanceof CssLinkInfo) { + CssLinkInfo other = (CssLinkInfo) obj; + return (this.start == other.start && this.end == other.end); + } + return false; + } + + @Override + public int hashCode() { + return this.start * 31 + this.end; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/DefaultResourceResolverChain.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/DefaultResourceResolverChain.java new file mode 100644 index 0000000000..a0fa35dba6 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/DefaultResourceResolverChain.java @@ -0,0 +1,90 @@ +/* + * 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.resource; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * A default implementation of {@link ResourceResolverChain} for invoking a list + * of {@link ResourceResolver}s. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class DefaultResourceResolverChain implements ResourceResolverChain { + + private final List resolvers = new ArrayList<>(); + + private int index = -1; + + + public DefaultResourceResolverChain(List resolvers) { + if (resolvers != null) { + this.resolvers.addAll(resolvers); + } + } + + + @Override + public Resource resolveResource(ServerWebExchange exchange, String requestPath, + List locations) { + + ResourceResolver resolver = getNext(); + if (resolver == null) { + return null; + } + + try { + return resolver.resolveResource(exchange, requestPath, locations, this); + } + finally { + this.index--; + } + } + + @Override + public String resolveUrlPath(String resourcePath, List locations) { + ResourceResolver resolver = getNext(); + if (resolver == null) { + return null; + } + + try { + return resolver.resolveUrlPath(resourcePath, locations, this); + } + finally { + this.index--; + } + } + + private ResourceResolver getNext() { + Assert.state(this.index <= this.resolvers.size(), + "Current index exceeds the number of configured ResourceResolvers"); + + if (this.index == (this.resolvers.size() - 1)) { + return null; + } + this.index++; + return this.resolvers.get(this.index); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/DefaultResourceTransformerChain.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/DefaultResourceTransformerChain.java new file mode 100644 index 0000000000..58323e4e16 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/DefaultResourceTransformerChain.java @@ -0,0 +1,85 @@ +/* + * 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.resource; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * A default implementation of {@link ResourceTransformerChain} for invoking + * a list of {@link ResourceTransformer}s. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class DefaultResourceTransformerChain implements ResourceTransformerChain { + + private final ResourceResolverChain resolverChain; + + private final List transformers = new ArrayList<>(); + + private int index = -1; + + + public DefaultResourceTransformerChain(ResourceResolverChain resolverChain, + List transformers) { + + Assert.notNull(resolverChain, "ResourceResolverChain is required"); + this.resolverChain = resolverChain; + if (transformers != null) { + this.transformers.addAll(transformers); + } + } + + + public ResourceResolverChain getResolverChain() { + return this.resolverChain; + } + + + @Override + public Resource transform(ServerWebExchange exchange, Resource resource) throws IOException { + ResourceTransformer transformer = getNext(); + if (transformer == null) { + return resource; + } + try { + return transformer.transform(exchange, resource, this); + } + finally { + this.index--; + } + } + + private ResourceTransformer getNext() { + Assert.state(this.index <= this.transformers.size(), + "Current index exceeds the number of configured ResourceTransformer's"); + + if (this.index == (this.transformers.size() - 1)) { + return null; + } + + this.index++; + return this.transformers.get(this.index); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/EncodedResource.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/EncodedResource.java new file mode 100644 index 0000000000..20a603bc30 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/EncodedResource.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2015 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.resource; + +import org.springframework.core.io.Resource; + +/** + * Interface for a resource descriptor that describes the encoding + * applied to the entire resource content. + * + *

This information is required if the client consuming that resource + * needs additional decoding capabilities to retrieve the resource's content. + * + * @author Rossen Stoyanchev + * @since 5.0 + * @see + * HTTP/1.1: Semantics and Content, section 3.1.2.2 + */ +public interface EncodedResource extends Resource { + + /** + * The content coding value, as defined in the IANA registry + * @return the content encoding + * @see HTTP/1.1: Semantics + * and Content, section 3.1.2.1 + */ + String getContentEncoding(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/FixedVersionStrategy.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/FixedVersionStrategy.java new file mode 100644 index 0000000000..9a8aea12fa --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/FixedVersionStrategy.java @@ -0,0 +1,54 @@ +/* + * 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.resource; + +import org.springframework.core.io.Resource; + +/** + * A {@code VersionStrategy} that relies on a fixed version applied as a request + * path prefix, e.g. reduced SHA, version name, release date, etc. + * + *

This is useful for example when {@link ContentVersionStrategy} cannot be + * used such as when using JavaScript module loaders which are in charge of + * loading the JavaScript resources and need to know their relative paths. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + * @see VersionResourceResolver + */ +public class FixedVersionStrategy extends AbstractVersionStrategy { + + private final String version; + + + /** + * Create a new FixedVersionStrategy with the given version string. + * @param version the fixed version string to use + */ + public FixedVersionStrategy(String version) { + super(new PrefixVersionPathStrategy(version)); + this.version = version; + } + + + @Override + public String getResourceVersion(Resource resource) { + return this.version; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/GzipResourceResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/GzipResourceResolver.java new file mode 100644 index 0000000000..cc1f2218c6 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/GzipResourceResolver.java @@ -0,0 +1,146 @@ +/* + * 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.resource; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.List; + +import org.springframework.core.io.AbstractResource; +import org.springframework.core.io.Resource; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@code ResourceResolver} that delegates to the chain to locate a resource + * and then attempts to find a variation with the ".gz" extension. + * + *

The resolver gets involved only if the "Accept-Encoding" request header + * contains the value "gzip" indicating the client accepts gzipped responses. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class GzipResourceResolver extends AbstractResourceResolver { + + @Override + protected Resource resolveResourceInternal(ServerWebExchange exchange, String requestPath, + List locations, ResourceResolverChain chain) { + + Resource resource = chain.resolveResource(exchange, requestPath, locations); + if ((resource == null) || (exchange != null && !isGzipAccepted(exchange))) { + return resource; + } + + try { + Resource gzipped = new GzippedResource(resource); + if (gzipped.exists()) { + return gzipped; + } + } + catch (IOException ex) { + logger.trace("No gzipped resource for [" + resource.getFilename() + "]", ex); + } + + return resource; + } + + private boolean isGzipAccepted(ServerWebExchange exchange) { + String value = exchange.getRequest().getHeaders().getFirst("Accept-Encoding"); + return (value != null && value.toLowerCase().contains("gzip")); + } + + @Override + protected String resolveUrlPathInternal(String resourceUrlPath, List locations, + ResourceResolverChain chain) { + + return chain.resolveUrlPath(resourceUrlPath, locations); + } + + + private static final class GzippedResource extends AbstractResource implements EncodedResource { + + private final Resource original; + + private final Resource gzipped; + + public GzippedResource(Resource original) throws IOException { + this.original = original; + this.gzipped = original.createRelative(original.getFilename() + ".gz"); + } + + public InputStream getInputStream() throws IOException { + return this.gzipped.getInputStream(); + } + + public boolean exists() { + return this.gzipped.exists(); + } + + public boolean isReadable() { + return this.gzipped.isReadable(); + } + + public boolean isOpen() { + return this.gzipped.isOpen(); + } + + @Override + public boolean isFile() { + return this.gzipped.isFile(); + } + + public URL getURL() throws IOException { + return this.gzipped.getURL(); + } + + public URI getURI() throws IOException { + return this.gzipped.getURI(); + } + + public File getFile() throws IOException { + return this.gzipped.getFile(); + } + + public long contentLength() throws IOException { + return this.gzipped.contentLength(); + } + + public long lastModified() throws IOException { + return this.gzipped.lastModified(); + } + + public Resource createRelative(String relativePath) throws IOException { + return this.gzipped.createRelative(relativePath); + } + + public String getFilename() { + return this.original.getFilename(); + } + + public String getDescription() { + return this.gzipped.getDescription(); + } + + public String getContentEncoding() { + return "gzip"; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java new file mode 100644 index 0000000000..713ddb17df --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java @@ -0,0 +1,196 @@ +/* + * 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.resource; + +import java.io.IOException; +import java.net.URLDecoder; +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * A simple {@code ResourceResolver} that tries to find a resource under the given + * locations matching to the request path. + * + *

This resolver does not delegate to the {@code ResourceResolverChain} and is + * expected to be configured at the end in a chain of resolvers. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class PathResourceResolver extends AbstractResourceResolver { + + private Resource[] allowedLocations; + + + /** + * By default when a Resource is found, the path of the resolved resource is + * compared to ensure it's under the input location where it was found. + * However sometimes that may not be the case, e.g. when + * {@link CssLinkResourceTransformer} + * resolves public URLs of links it contains, the CSS file is the location + * and the resources being resolved are css files, images, fonts and others + * located in adjacent or parent directories. + *

This property allows configuring a complete list of locations under + * which resources must be so that if a resource is not under the location + * relative to which it was found, this list may be checked as well. + *

By default {@link ResourceWebHandler} initializes this property + * to match its list of locations. + * @param locations the list of allowed locations + */ + public void setAllowedLocations(Resource... locations) { + this.allowedLocations = locations; + } + + public Resource[] getAllowedLocations() { + return this.allowedLocations; + } + + + @Override + protected Resource resolveResourceInternal(ServerWebExchange exchange, String requestPath, + List locations, ResourceResolverChain chain) { + + return getResource(requestPath, locations); + } + + @Override + protected String resolveUrlPathInternal(String path, List locations, + ResourceResolverChain chain) { + + return (StringUtils.hasText(path) && getResource(path, locations) != null ? path : null); + } + + private Resource getResource(String resourcePath, List locations) { + for (Resource location : locations) { + try { + if (logger.isTraceEnabled()) { + logger.trace("Checking location: " + location); + } + Resource resource = getResource(resourcePath, location); + if (resource != null) { + if (logger.isTraceEnabled()) { + logger.trace("Found match: " + resource); + } + return resource; + } + else if (logger.isTraceEnabled()) { + logger.trace("No match for location: " + location); + } + } + catch (IOException ex) { + logger.trace("Failure checking for relative resource - trying next location", ex); + } + } + return null; + } + + /** + * Find the resource under the given location. + *

The default implementation checks if there is a readable + * {@code Resource} for the given path relative to the location. + * @param resourcePath the path to the resource + * @param location the location to check + * @return the resource, or {@code null} if none found + */ + protected Resource getResource(String resourcePath, Resource location) throws IOException { + Resource resource = location.createRelative(resourcePath); + if (resource.exists() && resource.isReadable()) { + if (checkResource(resource, location)) { + return resource; + } + else if (logger.isTraceEnabled()) { + logger.trace("Resource path=\"" + resourcePath + "\" was successfully resolved " + + "but resource=\"" + resource.getURL() + "\" is neither under the " + + "current location=\"" + location.getURL() + "\" nor under any of the " + + "allowed locations=" + Arrays.asList(getAllowedLocations())); + } + } + return null; + } + + /** + * Perform additional checks on a resolved resource beyond checking whether the + * resources exists and is readable. The default implementation also verifies + * the resource is either under the location relative to which it was found or + * is under one of the {@link #setAllowedLocations allowed locations}. + * @param resource the resource to check + * @param location the location relative to which the resource was found + * @return "true" if resource is in a valid location, "false" otherwise. + */ + protected boolean checkResource(Resource resource, Resource location) throws IOException { + if (isResourceUnderLocation(resource, location)) { + return true; + } + if (getAllowedLocations() != null) { + for (Resource current : getAllowedLocations()) { + if (isResourceUnderLocation(resource, current)) { + return true; + } + } + } + return false; + } + + private boolean isResourceUnderLocation(Resource resource, Resource location) throws IOException { + if (resource.getClass() != location.getClass()) { + return false; + } + + String resourcePath; + String locationPath; + + if (resource instanceof UrlResource) { + resourcePath = resource.getURL().toExternalForm(); + locationPath = StringUtils.cleanPath(location.getURL().toString()); + } + else if (resource instanceof ClassPathResource) { + resourcePath = ((ClassPathResource) resource).getPath(); + locationPath = StringUtils.cleanPath(((ClassPathResource) location).getPath()); + } + else { + resourcePath = resource.getURL().getPath(); + locationPath = StringUtils.cleanPath(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("%")) { + // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars... + if (URLDecoder.decode(resourcePath, "UTF-8").contains("../")) { + if (logger.isTraceEnabled()) { + logger.trace("Resolved resource path contains \"../\" after decoding: " + resourcePath); + } + return false; + } + } + + return true; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceResolver.java new file mode 100644 index 0000000000..ff6256b6f7 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceResolver.java @@ -0,0 +1,60 @@ +/* + * 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.resource; + +import java.util.List; + +import org.springframework.core.io.Resource; +import org.springframework.web.server.ServerWebExchange; + +/** + * A strategy for resolving a request to a server-side resource. + * + *

Provides mechanisms for resolving an incoming request to an actual + * {@link Resource} and for obtaining the + * public URL path that clients should use when requesting the resource. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public interface ResourceResolver { + + /** + * Resolve the supplied request and request path to a {@link Resource} that + * exists under one of the given resource locations. + * @param exchange the current exchange + * @param requestPath the portion of the request path to use + * @param locations the locations to search in when looking up resources + * @param chain the chain of remaining resolvers to delegate to + * @return the resolved resource or {@code null} if unresolved + */ + Resource resolveResource(ServerWebExchange exchange, String requestPath, List locations, + ResourceResolverChain chain); + + /** + * Resolve the externally facing public URL path for clients to use + * to access the resource that is located at the given internal + * resource path. + *

This is useful when rendering URL links to clients. + * @param resourcePath the internal resource path + * @param locations the locations to search in when looking up resources + * @param chain the chain of resolvers to delegate to + * @return the resolved public URL path or {@code null} if unresolved + */ + String resolveUrlPath(String resourcePath, List locations, ResourceResolverChain chain); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceResolverChain.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceResolverChain.java new file mode 100644 index 0000000000..bd09f063bb --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceResolverChain.java @@ -0,0 +1,54 @@ +/* + * 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.resource; + +import java.util.List; + +import org.springframework.core.io.Resource; +import org.springframework.web.server.ServerWebExchange; + +/** + * A contract for invoking a chain of {@link ResourceResolver}s where each resolver + * is given a reference to the chain allowing it to delegate when necessary. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public interface ResourceResolverChain { + + /** + * Resolve the supplied request and request path to a {@link Resource} that + * exists under one of the given resource locations. + * @param exchange the current exchange + * @param requestPath the portion of the request path to use + * @param locations the locations to search in when looking up resources + * @return the resolved resource or {@code null} if unresolved + */ + Resource resolveResource(ServerWebExchange exchange, String requestPath, List locations); + + /** + * Resolve the externally facing public URL path for clients to use + * to access the resource that is located at the given internal + * resource path. + *

This is useful when rendering URL links to clients. + * @param resourcePath the internal resource path + * @param locations the locations to search in when looking up resources + * @return the resolved public URL path or {@code null} if unresolved + */ + String resolveUrlPath(String resourcePath, List locations); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformer.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformer.java new file mode 100644 index 0000000000..06f5c67def --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformer.java @@ -0,0 +1,44 @@ +/* + * 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.resource; + +import java.io.IOException; + +import org.springframework.core.io.Resource; +import org.springframework.web.server.ServerWebExchange; + +/** + * An abstraction for transforming the content of a resource. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +@FunctionalInterface +public interface ResourceTransformer { + + /** + * Transform the given resource. + * @param exchange the current exchange + * @param resource the resource to transform + * @param transformerChain the chain of remaining transformers to delegate to + * @return the transformed resource (never {@code null}) + * @throws IOException if the transformation fails + */ + Resource transform(ServerWebExchange exchange, Resource resource, + ResourceTransformerChain transformerChain) throws IOException; + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformerChain.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformerChain.java new file mode 100644 index 0000000000..714dcc666a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformerChain.java @@ -0,0 +1,49 @@ +/* + * 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.resource; + +import java.io.IOException; + +import org.springframework.core.io.Resource; +import org.springframework.web.server.ServerWebExchange; + +/** + * A contract for invoking a chain of {@link ResourceTransformer}s where each resolver + * is given a reference to the chain allowing it to delegate when necessary. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public interface ResourceTransformerChain { + + /** + * Return the {@code ResourceResolverChain} that was used to resolve the + * {@code Resource} being transformed. This may be needed for resolving + * related resources, e.g. links to other resources. + */ + ResourceResolverChain getResolverChain(); + + /** + * Transform the given resource. + * @param exchange the current exchange + * @param resource the candidate resource to transform + * @return the transformed or the same resource, never {@code null} + * @throws IOException if transformation fails + */ + Resource transform(ServerWebExchange exchange, Resource resource) throws IOException; + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformerSupport.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformerSupport.java new file mode 100644 index 0000000000..e2cec14715 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceTransformerSupport.java @@ -0,0 +1,82 @@ +/* + * 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.resource; + +import java.util.Collections; + +import org.springframework.core.io.Resource; +import org.springframework.web.server.ServerWebExchange; + +/** + * A base class for a {@code ResourceTransformer} with an optional helper method + * for resolving public links within a transformed resource. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + */ +public abstract class ResourceTransformerSupport implements ResourceTransformer { + + private ResourceUrlProvider resourceUrlProvider; + + + /** + * Configure a {@link ResourceUrlProvider} to use when resolving the public + * URL of links in a transformed resource (e.g. import links in a CSS file). + * This is required only for links expressed as full paths and not for + * relative links. + * @param resourceUrlProvider the URL provider to use + */ + public void setResourceUrlProvider(ResourceUrlProvider resourceUrlProvider) { + this.resourceUrlProvider = resourceUrlProvider; + } + + /** + * @return the configured {@code ResourceUrlProvider}. + */ + public ResourceUrlProvider getResourceUrlProvider() { + return this.resourceUrlProvider; + } + + + /** + * A transformer can use this method when a resource being transformed + * contains links to other resources. Such links need to be replaced with the + * public facing link as determined by the resource resolver chain (e.g. the + * public URL may have a version inserted). + * @param resourcePath the path to a resource that needs to be re-written + * @param exchange the current exchange + * @param resource the resource being transformed + * @param transformerChain the transformer chain + * @return the resolved URL or null + */ + protected String resolveUrlPath(String resourcePath, ServerWebExchange exchange, + Resource resource, ResourceTransformerChain transformerChain) { + + if (resourcePath.startsWith("/")) { + // full resource path + ResourceUrlProvider urlProvider = getResourceUrlProvider(); + return (urlProvider != null ? urlProvider.getForRequestUrl(exchange, resourcePath) : null); + } + else { + // try resolving as relative path + return transformerChain.getResolverChain() + .resolveUrlPath(resourcePath, Collections.singletonList(resource)); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceUrlProvider.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceUrlProvider.java new file mode 100644 index 0000000000..5434ccea03 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceUrlProvider.java @@ -0,0 +1,247 @@ +/* + * 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.resource; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; +import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.HttpRequestPathHelper; + +/** + * A central component to use to obtain the public URL path that clients should + * use to access a static resource. + * + *

This class is aware of Spring MVC handler mappings used to serve static + * resources and uses the {@code ResourceResolver} chains of the configured + * {@code ResourceHttpRequestHandler}s to make its decisions. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ResourceUrlProvider implements ApplicationListener { + + protected final Log logger = LogFactory.getLog(getClass()); + + private HttpRequestPathHelper urlPathHelper = new HttpRequestPathHelper(); + + private PathMatcher pathMatcher = new AntPathMatcher(); + + private final Map handlerMap = new LinkedHashMap<>(); + + private boolean autodetect = true; + + + /** + * Configure a {@code UrlPathHelper} to use in + * {@link #getForRequestUrl(ServerWebExchange, String)} + * in order to derive the lookup path for a target request URL path. + */ + public void setUrlPathHelper(HttpRequestPathHelper urlPathHelper) { + this.urlPathHelper = urlPathHelper; + } + + /** + * Return the configured {@code UrlPathHelper}. + */ + public HttpRequestPathHelper getUrlPathHelper() { + return this.urlPathHelper; + } + + /** + * Configure a {@code PathMatcher} to use when comparing target lookup path + * against resource mappings. + */ + public void setPathMatcher(PathMatcher pathMatcher) { + this.pathMatcher = pathMatcher; + } + + /** + * Return the configured {@code PathMatcher}. + */ + public PathMatcher getPathMatcher() { + return this.pathMatcher; + } + + /** + * Manually configure the resource mappings. + *

Note: by default resource mappings are auto-detected + * from the Spring {@code ApplicationContext}. However if this property is + * used, the auto-detection is turned off. + */ + public void setHandlerMap(Map handlerMap) { + if (handlerMap != null) { + this.handlerMap.clear(); + this.handlerMap.putAll(handlerMap); + this.autodetect = false; + } + } + + /** + * Return the resource mappings, either manually configured or auto-detected + * when the Spring {@code ApplicationContext} is refreshed. + */ + public Map getHandlerMap() { + return this.handlerMap; + } + + /** + * Return {@code false} if resource mappings were manually configured, + * {@code true} otherwise. + */ + public boolean isAutodetect() { + return this.autodetect; + } + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (isAutodetect()) { + this.handlerMap.clear(); + detectResourceHandlers(event.getApplicationContext()); + if (this.handlerMap.isEmpty() && logger.isDebugEnabled()) { + logger.debug("No resource handling mappings found"); + } + if (!this.handlerMap.isEmpty()) { + this.autodetect = false; + } + } + } + + + protected void detectResourceHandlers(ApplicationContext appContext) { + logger.debug("Looking for resource handler mappings"); + + Map map = appContext.getBeansOfType(SimpleUrlHandlerMapping.class); + List handlerMappings = new ArrayList<>(map.values()); + AnnotationAwareOrderComparator.sort(handlerMappings); + + for (SimpleUrlHandlerMapping hm : handlerMappings) { + for (String pattern : hm.getHandlerMap().keySet()) { + Object handler = hm.getHandlerMap().get(pattern); + if (handler instanceof ResourceWebHandler) { + ResourceWebHandler resourceHandler = (ResourceWebHandler) handler; + if (logger.isDebugEnabled()) { + logger.debug("Found resource handler mapping: URL pattern=\"" + pattern + "\", " + + "locations=" + resourceHandler.getLocations() + ", " + + "resolvers=" + resourceHandler.getResourceResolvers()); + } + this.handlerMap.put(pattern, resourceHandler); + } + } + } + } + + /** + * A variation on {@link #getForLookupPath(String)} that accepts a full request + * URL path and returns the full request URL path to expose for public use. + * @param exchange the current exchange + * @param requestUrl the request URL path to resolve + * @return the resolved public URL path, or {@code null} if unresolved + */ + public final String getForRequestUrl(ServerWebExchange exchange, String requestUrl) { + if (logger.isTraceEnabled()) { + logger.trace("Getting resource URL for request URL \"" + requestUrl + "\""); + } + int prefixIndex = getLookupPathIndex(exchange); + int suffixIndex = getQueryParamsIndex(requestUrl); + String prefix = requestUrl.substring(0, prefixIndex); + String suffix = requestUrl.substring(suffixIndex); + String lookupPath = requestUrl.substring(prefixIndex, suffixIndex); + String resolvedLookupPath = getForLookupPath(lookupPath); + return (resolvedLookupPath != null ? prefix + resolvedLookupPath + suffix : null); + } + + private int getLookupPathIndex(ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + String requestPath = request.getURI().getPath(); + String lookupPath = getUrlPathHelper().getLookupPathForRequest(exchange); + return requestPath.indexOf(lookupPath); + } + + private int getQueryParamsIndex(String lookupPath) { + int index = lookupPath.indexOf("?"); + return index > 0 ? index : lookupPath.length(); + } + + /** + * Compare the given path against configured resource handler mappings and + * if a match is found use the {@code ResourceResolver} chain of the matched + * {@code ResourceHttpRequestHandler} to resolve the URL path to expose for + * public use. + *

It is expected that the given path is what Spring uses for + * request mapping purposes. + *

If several handler mappings match, the handler used will be the one + * configured with the most specific pattern. + * @param lookupPath the lookup path to check + * @return the resolved public URL path, or {@code null} if unresolved + */ + public final String getForLookupPath(String lookupPath) { + if (logger.isTraceEnabled()) { + logger.trace("Getting resource URL for lookup path \"" + lookupPath + "\""); + } + + List matchingPatterns = new ArrayList<>(); + for (String pattern : this.handlerMap.keySet()) { + if (getPathMatcher().match(pattern, lookupPath)) { + matchingPatterns.add(pattern); + } + } + + if (!matchingPatterns.isEmpty()) { + Comparator patternComparator = getPathMatcher().getPatternComparator(lookupPath); + Collections.sort(matchingPatterns, patternComparator); + for (String pattern : matchingPatterns) { + String pathWithinMapping = getPathMatcher().extractPathWithinPattern(pattern, lookupPath); + String pathMapping = lookupPath.substring(0, lookupPath.indexOf(pathWithinMapping)); + if (logger.isTraceEnabled()) { + logger.trace("Invoking ResourceResolverChain for URL pattern \"" + pattern + "\""); + } + ResourceWebHandler handler = this.handlerMap.get(pattern); + ResourceResolverChain chain = new DefaultResourceResolverChain(handler.getResourceResolvers()); + String resolved = chain.resolveUrlPath(pathWithinMapping, handler.getLocations()); + if (resolved == null) { + continue; + } + if (logger.isTraceEnabled()) { + logger.trace("Resolved public resource URL path \"" + resolved + "\""); + } + return pathMapping + resolved; + } + } + + if (logger.isDebugEnabled()) { + logger.debug("No matching resource mapping for lookup path \"" + lookupPath + "\""); + } + return null; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java new file mode 100644 index 0000000000..8be303f20f --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java @@ -0,0 +1,500 @@ +/* + * 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.resource; + +import java.io.IOException; +import java.net.URLDecoder; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.ResolvableType; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ResourceHttpMessageWriter; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.accept.CompositeContentTypeResolver; +import org.springframework.web.reactive.accept.PathExtensionContentTypeResolver; +import org.springframework.web.server.MethodNotAllowedException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebHandler; + +/** + * {@code HttpRequestHandler} that serves static resources in an optimized way + * according to the guidelines of Page Speed, YSlow, etc. + * + *

The {@linkplain #setLocations "locations"} property takes a list of Spring + * {@link Resource} locations from which static resources are allowed to + * be served by this handler. Resources could be served from a classpath location, + * e.g. "classpath:/META-INF/public-web-resources/", allowing convenient packaging + * and serving of resources such as .js, .css, and others in jar files. + * + *

This request handler may also be configured with a + * {@link #setResourceResolvers(List) resourcesResolver} and + * {@link #setResourceTransformers(List) resourceTransformer} chains to support + * arbitrary resolution and transformation of resources being served. By default a + * {@link PathResourceResolver} simply finds resources based on the configured + * "locations". An application can configure additional resolvers and + * transformers such as the {@link VersionResourceResolver} which can resolve + * and prepare URLs for resources with a version in the URL. + * + *

This handler also properly evaluates the {@code Last-Modified} header (if + * present) so that a {@code 304} status code will be returned as appropriate, + * avoiding unnecessary overhead for resources that are already cached by the + * client. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ResourceWebHandler + implements WebHandler, InitializingBean, SmartInitializingSingleton { + + /** Set of supported HTTP methods */ + private static final Set SUPPORTED_METHODS = new LinkedHashSet<>(2); + + private static final Log logger = LogFactory.getLog(ResourceWebHandler.class); + + static { + SUPPORTED_METHODS.addAll(Arrays.asList("GET", "HEAD")); + } + + + private final List locations = new ArrayList<>(4); + + private final List resourceResolvers = new ArrayList<>(4); + + private final List resourceTransformers = new ArrayList<>(4); + + private CacheControl cacheControl; + + private ResourceHttpMessageWriter resourceHttpMessageWriter; + + private CompositeContentTypeResolver contentTypeResolver; + + private PathExtensionContentTypeResolver pathExtensionResolver; + + + /** + * Set the {@code List} of {@code Resource} paths to use as sources + * for serving static resources. + */ + public void setLocations(List locations) { + Assert.notNull(locations, "Locations list must not be null"); + this.locations.clear(); + this.locations.addAll(locations); + } + + /** + * Return the {@code List} of {@code Resource} paths to use as sources + * for serving static resources. + */ + public List getLocations() { + return this.locations; + } + + /** + * Configure the list of {@link ResourceResolver}s to use. + *

By default {@link PathResourceResolver} is configured. If using this property, + * it is recommended to add {@link PathResourceResolver} as the last resolver. + */ + public void setResourceResolvers(List resourceResolvers) { + this.resourceResolvers.clear(); + if (resourceResolvers != null) { + this.resourceResolvers.addAll(resourceResolvers); + } + } + + /** + * Return the list of configured resource resolvers. + */ + public List getResourceResolvers() { + return this.resourceResolvers; + } + + /** + * Configure the list of {@link ResourceTransformer}s to use. + *

By default no transformers are configured for use. + */ + public void setResourceTransformers(List resourceTransformers) { + this.resourceTransformers.clear(); + if (resourceTransformers != null) { + this.resourceTransformers.addAll(resourceTransformers); + } + } + + /** + * Return the list of configured resource transformers. + */ + public List getResourceTransformers() { + return this.resourceTransformers; + } + + /** + * Set the {@link org.springframework.http.CacheControl} instance to build + * the Cache-Control HTTP response header. + */ + public void setCacheControl(CacheControl cacheControl) { + this.cacheControl = cacheControl; + } + + public CacheControl getCacheControl() { + return this.cacheControl; + } + + /** + * Configure the {@link ResourceHttpMessageWriter} to use. + *

By default a {@link ResourceHttpMessageWriter} will be configured. + */ + public void setResourceHttpMessageWriter(ResourceHttpMessageWriter httpMessageWriter) { + this.resourceHttpMessageWriter = httpMessageWriter; + } + + /** + * Return the list of configured resource converters. + */ + public ResourceHttpMessageWriter getResourceHttpMessageWriter() { + return this.resourceHttpMessageWriter; + } + + /** + * Configure a {@link CompositeContentTypeResolver} to help determine the + * media types for resources being served. If the manager contains a path + * extension resolver it will be checked for registered file extension. + * @param contentTypeResolver the resolver in use + */ + public void setContentTypeResolver(CompositeContentTypeResolver contentTypeResolver) { + this.contentTypeResolver = contentTypeResolver; + } + + /** + * Return the configured {@link CompositeContentTypeResolver}. + */ + public CompositeContentTypeResolver getContentTypeResolver() { + return this.contentTypeResolver; + } + + @Override + public void afterPropertiesSet() throws Exception { + if (logger.isWarnEnabled() && CollectionUtils.isEmpty(this.locations)) { + logger.warn("Locations list is empty. No resources will be served unless a " + + "custom ResourceResolver is configured as an alternative to PathResourceResolver."); + } + if (this.resourceResolvers.isEmpty()) { + this.resourceResolvers.add(new PathResourceResolver()); + } + initAllowedLocations(); + if (this.resourceHttpMessageWriter == null) { + this.resourceHttpMessageWriter = new ResourceHttpMessageWriter(); + } + } + + /** + * Look for a {@code PathResourceResolver} among the configured resource + * resolvers and set its {@code allowedLocations} property (if empty) to + * match the {@link #setLocations locations} configured on this class. + */ + protected void initAllowedLocations() { + if (CollectionUtils.isEmpty(this.locations)) { + return; + } + for (int i = getResourceResolvers().size() - 1; i >= 0; i--) { + if (getResourceResolvers().get(i) instanceof PathResourceResolver) { + PathResourceResolver resolver = (PathResourceResolver) getResourceResolvers().get(i); + if (ObjectUtils.isEmpty(resolver.getAllowedLocations())) { + resolver.setAllowedLocations(getLocations().toArray(new Resource[getLocations().size()])); + } + break; + } + } + } + + @Override + public void afterSingletonsInstantiated() { + this.pathExtensionResolver = initContentNegotiationStrategy(); + } + + protected PathExtensionContentTypeResolver initContentNegotiationStrategy() { + Map mediaTypes = null; + if (getContentTypeResolver() != null) { + PathExtensionContentTypeResolver strategy = + getContentTypeResolver().findResolver(PathExtensionContentTypeResolver.class); + if (strategy != null) { + mediaTypes = new HashMap<>(strategy.getMediaTypes()); + } + } + return new PathExtensionContentTypeResolver(mediaTypes); + } + + + /** + * Processes a resource request. + *

Checks for the existence of the requested resource in the configured list of locations. + * If the resource does not exist, a {@code 404} response will be returned to the client. + * If the resource exists, the request will be checked for the presence of the + * {@code Last-Modified} header, and its value will be compared against the last-modified + * timestamp of the given resource, returning a {@code 304} status code if the + * {@code Last-Modified} value is greater. If the resource is newer than the + * {@code Last-Modified} value, or the header is not present, the content resource + * of the resource will be written to the response with caching headers + * set to expire one year in the future. + */ + @Override + public Mono handle(ServerWebExchange exchange) { + try { + // For very general mappings (e.g. "/") we need to check 404 first + Resource resource = getResource(exchange); + if (resource == null) { + logger.trace("No matching resource found - returning 404"); + exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); + return Mono.empty(); + } + + if (HttpMethod.OPTIONS.equals(exchange.getRequest().getMethod())) { + exchange.getResponse().getHeaders().add("Allow", "GET,HEAD,OPTIONS"); + return Mono.empty(); + } + + // Supported methods and required session + String httpMehtod = exchange.getRequest().getMethod().name(); + if (!SUPPORTED_METHODS.contains(httpMehtod)) { + return Mono.error(new MethodNotAllowedException(httpMehtod, SUPPORTED_METHODS)); + } + + // Header phase + if (exchange.checkNotModified(Instant.ofEpochMilli(resource.lastModified()))) { + logger.trace("Resource not modified - returning 304"); + return Mono.empty(); + } + + // Apply cache settings, if any + if (getCacheControl() != null) { + String value = getCacheControl().getHeaderValue(); + if (value != null) { + exchange.getResponse().getHeaders().setCacheControl(value); + } + } + + // Check the media type for the resource + MediaType mediaType = getMediaType(exchange, resource); + if (mediaType != null) { + if (logger.isTraceEnabled()) { + logger.trace("Determined media type '" + mediaType + "' for " + resource); + } + } + else { + if (logger.isTraceEnabled()) { + logger.trace("No media type found for " + resource + " - not sending a content-type header"); + } + } + + // Content phase + if (HttpMethod.HEAD.equals(exchange.getRequest().getMethod())) { + setHeaders(exchange, resource, mediaType); + logger.trace("HEAD request - skipping content"); + return Mono.empty(); + } + + // TODO: range requests + + setHeaders(exchange, resource, mediaType); + return this.resourceHttpMessageWriter.write(Mono.just(resource), + ResolvableType.forClass(Resource.class), mediaType, exchange.getResponse()); + } + catch (IOException ex) { + return Mono.error(ex); + } + } + + protected Resource getResource(ServerWebExchange exchange) throws IOException { + String attrName = HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE; + Optional optional = exchange.getAttribute(attrName); + if (!optional.isPresent()) { + throw new IllegalStateException("Required request attribute '" + attrName + "' is not set"); + } + String path = processPath(optional.get()); + if (!StringUtils.hasText(path) || isInvalidPath(path)) { + if (logger.isTraceEnabled()) { + logger.trace("Ignoring invalid resource path [" + path + "]"); + } + return null; + } + if (path.contains("%")) { + try { + // Use URLDecoder (vs UriUtils) to preserve potentially decoded UTF-8 chars + if (isInvalidPath(URLDecoder.decode(path, "UTF-8"))) { + if (logger.isTraceEnabled()) { + logger.trace("Ignoring invalid resource path with escape sequences [" + path + "]."); + } + return null; + } + } + catch (IllegalArgumentException ex) { + // ignore + } + } + ResourceResolverChain resolveChain = new DefaultResourceResolverChain(getResourceResolvers()); + Resource resource = resolveChain.resolveResource(exchange, path, getLocations()); + if (resource == null || getResourceTransformers().isEmpty()) { + return resource; + } + + ResourceTransformerChain transformChain = + new DefaultResourceTransformerChain(resolveChain, getResourceTransformers()); + resource = transformChain.transform(exchange, resource); + return resource; + } + + /** + * Process the given resource path to be used. + *

The default implementation replaces any combination of leading '/' and + * control characters (00-1F and 7F) with a single "/" or "". For example + * {@code " // /// //// foo/bar"} becomes {@code "/foo/bar"}. + */ + protected 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); + if (logger.isTraceEnabled()) { + logger.trace("Path after trimming leading '/' and control characters: " + path); + } + return path; + } + } + return (slash ? "/" : ""); + } + + /** + * Identifies invalid resource paths. By default rejects: + *

    + *
  • Paths that contain "WEB-INF" or "META-INF" + *
  • Paths that contain "../" after a call to + * {@link StringUtils#cleanPath}. + *
  • Paths that represent a {@link ResourceUtils#isUrl + * valid URL} or would represent one after the leading slash is removed. + *
+ *

Note: this method assumes that leading, duplicate '/' + * or control characters (e.g. white space) have been trimmed so that the + * path starts predictably with a single '/' or does not have one. + * @param path the path to validate + * @return {@code true} if the path is invalid, {@code false} otherwise + */ + protected boolean isInvalidPath(String path) { + if (logger.isTraceEnabled()) { + logger.trace("Applying \"invalid path\" checks to path: " + path); + } + if (path.contains("WEB-INF") || path.contains("META-INF")) { + if (logger.isTraceEnabled()) { + logger.trace("Path contains \"WEB-INF\" or \"META-INF\"."); + } + return true; + } + if (path.contains(":/")) { + String relativePath = (path.charAt(0) == '/' ? path.substring(1) : path); + if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { + if (logger.isTraceEnabled()) { + logger.trace("Path represents URL or has \"url:\" prefix."); + } + return true; + } + } + if (path.contains("..")) { + path = StringUtils.cleanPath(path); + if (path.contains("../")) { + if (logger.isTraceEnabled()) { + logger.trace("Path contains \"../\" after call to StringUtils#cleanPath."); + } + return true; + } + } + return false; + } + + /** + * Determine the media type for the given request and the resource matched + * to it. This implementation tries to determine the MediaType based on the + * file extension of the Resource via + * {@link PathExtensionContentTypeResolver#resolveMediaTypeForResource(Resource)}. + * @param exchange the current exchange + * @param resource the resource to check + * @return the corresponding media type, or {@code null} if none found + */ + protected MediaType getMediaType(ServerWebExchange exchange, Resource resource) { + return this.pathExtensionResolver.resolveMediaTypeForResource(resource); + } + + /** + * Set headers on the response. Called for both GET and HEAD requests. + * @param exchange current exchange + * @param resource the identified resource (never {@code null}) + * @param mediaType the resource's media type (never {@code null}) + * @throws IOException in case of errors while setting the headers + */ + protected void setHeaders(ServerWebExchange exchange, Resource resource, MediaType mediaType) + throws IOException { + + HttpHeaders headers = exchange.getResponse().getHeaders(); + + long length = resource.contentLength(); + headers.setContentLength(length); + + if (mediaType != null) { + headers.setContentType(mediaType); + } + if (resource instanceof EncodedResource) { + headers.set(HttpHeaders.CONTENT_ENCODING, ((EncodedResource) resource).getContentEncoding()); + } + if (resource instanceof VersionedResource) { + headers.setETag("\"" + ((VersionedResource) resource).getVersion() + "\""); + } + headers.set(HttpHeaders.ACCEPT_RANGES, "bytes"); + } + + + @Override + public String toString() { + return "ResourceWebHandler [locations=" + getLocations() + ", resolvers=" + getResourceResolvers() + "]"; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/TransformedResource.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/TransformedResource.java new file mode 100644 index 0000000000..490d17a17a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/TransformedResource.java @@ -0,0 +1,62 @@ +/* + * 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.resource; + +import java.io.IOException; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +/** + * An extension of {@link ByteArrayResource} + * that a {@link ResourceTransformer} can use to represent an original + * resource preserving all other information except the content. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class TransformedResource extends ByteArrayResource { + + private final String filename; + + private final long lastModified; + + + public TransformedResource(Resource original, byte[] transformedContent) { + super(transformedContent); + this.filename = original.getFilename(); + try { + this.lastModified = original.lastModified(); + } + catch (IOException ex) { + // should never happen + throw new IllegalArgumentException(ex); + } + } + + + @Override + public String getFilename() { + return this.filename; + } + + @Override + public long lastModified() throws IOException { + return this.lastModified; + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionPathStrategy.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionPathStrategy.java new file mode 100644 index 0000000000..c522c6901e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionPathStrategy.java @@ -0,0 +1,52 @@ +/* + * 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.resource; + +/** + * A strategy for extracting and embedding a resource version in its URL path. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 +*/ +public interface VersionPathStrategy { + + /** + * Extract the resource version from the request path. + * @param requestPath the request path to check + * @return the version string or {@code null} if none was found + */ + String extractVersion(String requestPath); + + /** + * Remove the version from the request path. It is assumed that the given + * version was extracted via {@link #extractVersion(String)}. + * @param requestPath the request path of the resource being resolved + * @param version the version obtained from {@link #extractVersion(String)} + * @return the request path with the version removed + */ + String removeVersion(String requestPath, String version); + + /** + * Add a version to the given request path. + * @param requestPath the requestPath + * @param version the version + * @return the requestPath updated with a version string + */ + String addVersion(String requestPath, String version); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java new file mode 100644 index 0000000000..bf71c5507d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java @@ -0,0 +1,326 @@ +/* + * 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.resource; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.core.io.AbstractResource; +import org.springframework.core.io.Resource; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolves request paths containing a version string that can be used as part + * of an HTTP caching strategy in which a resource is cached with a date in the + * distant future (e.g. 1 year) and cached until the version, and therefore the + * URL, is changed. + * + *

Different versioning strategies exist, and this resolver must be configured + * with one or more such strategies along with path mappings to indicate which + * strategy applies to which resources. + * + *

{@code ContentVersionStrategy} is a good default choice except in cases + * where it cannot be used. Most notably the {@code ContentVersionStrategy} + * cannot be combined with JavaScript module loaders. For such cases the + * {@code FixedVersionStrategy} is a better choice. + * + *

Note that using this resolver to serve CSS files means that the + * {@link CssLinkResourceTransformer} should also be used in order to modify + * links within CSS files to also contain the appropriate versions generated + * by this resolver. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + * @see VersionStrategy + */ +public class VersionResourceResolver extends AbstractResourceResolver { + + private AntPathMatcher pathMatcher = new AntPathMatcher(); + + /** Map from path pattern -> VersionStrategy */ + private final Map versionStrategyMap = new LinkedHashMap<>(); + + + /** + * Set a Map with URL paths as keys and {@code VersionStrategy} as values. + *

Supports direct URL matches and Ant-style pattern matches. For syntax + * details, see the {@link AntPathMatcher} javadoc. + * @param map map with URLs as keys and version strategies as values + */ + public void setStrategyMap(Map map) { + this.versionStrategyMap.clear(); + this.versionStrategyMap.putAll(map); + } + + /** + * Return the map with version strategies keyed by path pattern. + */ + public Map getStrategyMap() { + return this.versionStrategyMap; + } + + /** + * Insert a content-based version in resource URLs that match the given path + * patterns. The version is computed from the content of the file, e.g. + * {@code "css/main-e36d2e05253c6c7085a91522ce43a0b4.css"}. This is a good + * default strategy to use except when it cannot be, for example when using + * JavaScript module loaders, use {@link #addFixedVersionStrategy} instead + * for serving JavaScript files. + * @param pathPatterns one or more resource URL path patterns + * @return the current instance for chained method invocation + * @see ContentVersionStrategy + */ + public VersionResourceResolver addContentVersionStrategy(String... pathPatterns) { + addVersionStrategy(new ContentVersionStrategy(), pathPatterns); + return this; + } + + /** + * Insert a fixed, prefix-based version in resource URLs that match the given + * path patterns, for example: "{version}/js/main.js". This is useful (vs. + * content-based versions) when using JavaScript module loaders. + *

The version may be a random number, the current date, or a value + * fetched from a git commit sha, a property file, or environment variable + * and set with SpEL expressions in the configuration (e.g. see {@code @Value} + * in Java config). + *

If not done already, variants of the given {@code pathPatterns}, prefixed with + * the {@code version} will be also configured. For example, adding a {@code "/js/**"} path pattern + * will also cofigure automatically a {@code "/v1.0.0/js/**"} with {@code "v1.0.0"} the + * {@code version} String given as an argument. + * @param version a version string + * @param pathPatterns one or more resource URL path patterns + * @return the current instance for chained method invocation + * @see FixedVersionStrategy + */ + public VersionResourceResolver addFixedVersionStrategy(String version, String... pathPatterns) { + List patternsList = Arrays.asList(pathPatterns); + List prefixedPatterns = new ArrayList<>(pathPatterns.length); + String versionPrefix = "/" + version; + for (String pattern : patternsList) { + prefixedPatterns.add(pattern); + if (!pattern.startsWith(versionPrefix) && !patternsList.contains(versionPrefix + pattern)) { + prefixedPatterns.add(versionPrefix + pattern); + } + } + return addVersionStrategy(new FixedVersionStrategy(version), prefixedPatterns.toArray(new String[0])); + } + + /** + * Register a custom VersionStrategy to apply to resource URLs that match the + * given path patterns. + * @param strategy the custom strategy + * @param pathPatterns one or more resource URL path patterns + * @return the current instance for chained method invocation + * @see VersionStrategy + */ + public VersionResourceResolver addVersionStrategy(VersionStrategy strategy, String... pathPatterns) { + for (String pattern : pathPatterns) { + getStrategyMap().put(pattern, strategy); + } + return this; + } + + + @Override + protected Resource resolveResourceInternal(ServerWebExchange exchange, String requestPath, + List locations, ResourceResolverChain chain) { + + Resource resolved = chain.resolveResource(exchange, requestPath, locations); + if (resolved != null) { + return resolved; + } + + VersionStrategy versionStrategy = getStrategyForPath(requestPath); + if (versionStrategy == null) { + return null; + } + + String candidateVersion = versionStrategy.extractVersion(requestPath); + if (StringUtils.isEmpty(candidateVersion)) { + if (logger.isTraceEnabled()) { + logger.trace("No version found in path \"" + requestPath + "\""); + } + return null; + } + + String simplePath = versionStrategy.removeVersion(requestPath, candidateVersion); + if (logger.isTraceEnabled()) { + logger.trace("Extracted version from path, re-resolving without version: \"" + simplePath + "\""); + } + + Resource baseResource = chain.resolveResource(exchange, simplePath, locations); + if (baseResource == null) { + return null; + } + + String actualVersion = versionStrategy.getResourceVersion(baseResource); + if (candidateVersion.equals(actualVersion)) { + if (logger.isTraceEnabled()) { + logger.trace("Resource matches extracted version [" + candidateVersion + "]"); + } + return new FileNameVersionedResource(baseResource, candidateVersion); + } + else { + if (logger.isTraceEnabled()) { + logger.trace("Potential resource found for \"" + requestPath + "\", but version [" + + candidateVersion + "] does not match"); + } + return null; + } + } + + @Override + protected String resolveUrlPathInternal(String resourceUrlPath, List locations, + ResourceResolverChain chain) { + + String baseUrl = chain.resolveUrlPath(resourceUrlPath, locations); + if (StringUtils.hasText(baseUrl)) { + VersionStrategy versionStrategy = getStrategyForPath(resourceUrlPath); + if (versionStrategy == null) { + return null; + } + if (logger.isTraceEnabled()) { + logger.trace("Getting the original resource to determine version " + + "for path \"" + resourceUrlPath + "\""); + } + Resource resource = chain.resolveResource(null, baseUrl, locations); + String version = versionStrategy.getResourceVersion(resource); + if (logger.isTraceEnabled()) { + logger.trace("Determined version [" + version + "] for " + resource); + } + return versionStrategy.addVersion(baseUrl, version); + } + return baseUrl; + } + + /** + * Find a {@code VersionStrategy} for the request path of the requested resource. + * @return an instance of a {@code VersionStrategy} or null if none matches that request path + */ + protected VersionStrategy getStrategyForPath(String requestPath) { + String path = "/".concat(requestPath); + List matchingPatterns = new ArrayList<>(); + for (String pattern : this.versionStrategyMap.keySet()) { + if (this.pathMatcher.match(pattern, path)) { + matchingPatterns.add(pattern); + } + } + if (!matchingPatterns.isEmpty()) { + Comparator comparator = this.pathMatcher.getPatternComparator(path); + Collections.sort(matchingPatterns, comparator); + return this.versionStrategyMap.get(matchingPatterns.get(0)); + } + return null; + } + + + private class FileNameVersionedResource extends AbstractResource implements VersionedResource { + + private final Resource original; + + private final String version; + + public FileNameVersionedResource(Resource original, String version) { + this.original = original; + this.version = version; + } + + @Override + public boolean exists() { + return this.original.exists(); + } + + @Override + public boolean isReadable() { + return this.original.isReadable(); + } + + @Override + public boolean isOpen() { + return this.original.isOpen(); + } + + @Override + public boolean isFile() { + return this.original.isFile(); + } + + @Override + public URL getURL() throws IOException { + return this.original.getURL(); + } + + @Override + public URI getURI() throws IOException { + return this.original.getURI(); + } + + @Override + public File getFile() throws IOException { + return this.original.getFile(); + } + + @Override + public String getFilename() { + return this.original.getFilename(); + } + + @Override + public long contentLength() throws IOException { + return this.original.contentLength(); + } + + @Override + public long lastModified() throws IOException { + return this.original.lastModified(); + } + + @Override + public Resource createRelative(String relativePath) throws IOException { + return this.original.createRelative(relativePath); + } + + @Override + public String getDescription() { + return original.getDescription(); + } + + @Override + public InputStream getInputStream() throws IOException { + return original.getInputStream(); + } + + @Override + public String getVersion() { + return this.version; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionStrategy.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionStrategy.java new file mode 100644 index 0000000000..51d500702c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionStrategy.java @@ -0,0 +1,39 @@ +/* + * 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.resource; + +import org.springframework.core.io.Resource; + +/** + * An extension of {@link VersionPathStrategy} that adds a method + * to determine the actual version of a {@link Resource}. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + * @see VersionResourceResolver +*/ +public interface VersionStrategy extends VersionPathStrategy { + + /** + * Determine the version for the given resource. + * @param resource the resource to check + * @return the version (never {@code null}) + */ + String getResourceVersion(Resource resource); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionedResource.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionedResource.java new file mode 100644 index 0000000000..da44b3d108 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/VersionedResource.java @@ -0,0 +1,34 @@ +/* + * 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.resource; + +import org.springframework.core.io.Resource; + +/** + * Interface for a resource descriptor that describes its version with a + * version string that can be derived from its content and/or metadata. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 5.0 + * @see VersionResourceResolver + */ +public interface VersionedResource extends Resource { + + String getVersion(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/WebJarsResourceResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/WebJarsResourceResolver.java new file mode 100644 index 0000000000..b5b4b5683c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/resource/WebJarsResourceResolver.java @@ -0,0 +1,124 @@ +/* + * 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.resource; + +import java.util.List; + +import org.webjars.MultipleMatchesException; +import org.webjars.WebJarAssetLocator; + +import org.springframework.core.io.Resource; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@code ResourceResolver} that delegates to the chain to locate a resource and then + * attempts to find a matching versioned resource contained in a WebJar JAR file. + * + *

This allows WebJars.org users to write version agnostic paths in their templates, + * like {@code