001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.web.reactive.resource; 018 019import java.io.File; 020import java.io.IOException; 021import java.io.InputStream; 022import java.net.URI; 023import java.net.URL; 024import java.util.ArrayList; 025import java.util.Arrays; 026import java.util.Comparator; 027import java.util.LinkedHashMap; 028import java.util.List; 029import java.util.Map; 030 031import reactor.core.publisher.Mono; 032 033import org.springframework.core.io.AbstractResource; 034import org.springframework.core.io.Resource; 035import org.springframework.http.HttpHeaders; 036import org.springframework.lang.Nullable; 037import org.springframework.util.AntPathMatcher; 038import org.springframework.util.StringUtils; 039import org.springframework.web.server.ServerWebExchange; 040 041/** 042 * Resolves request paths containing a version string that can be used as part 043 * of an HTTP caching strategy in which a resource is cached with a date in the 044 * distant future (e.g. 1 year) and cached until the version, and therefore the 045 * URL, is changed. 046 * 047 * <p>Different versioning strategies exist, and this resolver must be configured 048 * with one or more such strategies along with path mappings to indicate which 049 * strategy applies to which resources. 050 * 051 * <p>{@code ContentVersionStrategy} is a good default choice except in cases 052 * where it cannot be used. Most notably the {@code ContentVersionStrategy} 053 * cannot be combined with JavaScript module loaders. For such cases the 054 * {@code FixedVersionStrategy} is a better choice. 055 * 056 * <p>Note that using this resolver to serve CSS files means that the 057 * {@link CssLinkResourceTransformer} should also be used in order to modify 058 * links within CSS files to also contain the appropriate versions generated 059 * by this resolver. 060 * 061 * @author Rossen Stoyanchev 062 * @author Brian Clozel 063 * @since 5.0 064 * @see VersionStrategy 065 */ 066public class VersionResourceResolver extends AbstractResourceResolver { 067 068 private AntPathMatcher pathMatcher = new AntPathMatcher(); 069 070 /** Map from path pattern -> VersionStrategy. */ 071 private final Map<String, VersionStrategy> versionStrategyMap = new LinkedHashMap<>(); 072 073 074 /** 075 * Set a Map with URL paths as keys and {@code VersionStrategy} as values. 076 * <p>Supports direct URL matches and Ant-style pattern matches. For syntax 077 * details, see the {@link AntPathMatcher} javadoc. 078 * @param map a map with URLs as keys and version strategies as values 079 */ 080 public void setStrategyMap(Map<String, VersionStrategy> map) { 081 this.versionStrategyMap.clear(); 082 this.versionStrategyMap.putAll(map); 083 } 084 085 /** 086 * Return the map with version strategies keyed by path pattern. 087 */ 088 public Map<String, VersionStrategy> getStrategyMap() { 089 return this.versionStrategyMap; 090 } 091 092 /** 093 * Insert a content-based version in resource URLs that match the given path 094 * patterns. The version is computed from the content of the file, e.g. 095 * {@code "css/main-e36d2e05253c6c7085a91522ce43a0b4.css"}. This is a good 096 * default strategy to use except when it cannot be, for example when using 097 * JavaScript module loaders, use {@link #addFixedVersionStrategy} instead 098 * for serving JavaScript files. 099 * @param pathPatterns one or more resource URL path patterns, 100 * relative to the pattern configured with the resource handler 101 * @return the current instance for chained method invocation 102 * @see ContentVersionStrategy 103 */ 104 public VersionResourceResolver addContentVersionStrategy(String... pathPatterns) { 105 addVersionStrategy(new ContentVersionStrategy(), pathPatterns); 106 return this; 107 } 108 109 /** 110 * Insert a fixed, prefix-based version in resource URLs that match the given 111 * path patterns, for example: <code>"{version}/js/main.js"</code>. This is useful (vs. 112 * content-based versions) when using JavaScript module loaders. 113 * <p>The version may be a random number, the current date, or a value 114 * fetched from a git commit sha, a property file, or environment variable 115 * and set with SpEL expressions in the configuration (e.g. see {@code @Value} 116 * in Java config). 117 * <p>If not done already, variants of the given {@code pathPatterns}, prefixed with 118 * the {@code version} will be also configured. For example, adding a {@code "/js/**"} path pattern 119 * will also cofigure automatically a {@code "/v1.0.0/js/**"} with {@code "v1.0.0"} the 120 * {@code version} String given as an argument. 121 * @param version a version string 122 * @param pathPatterns one or more resource URL path patterns, 123 * relative to the pattern configured with the resource handler 124 * @return the current instance for chained method invocation 125 * @see FixedVersionStrategy 126 */ 127 public VersionResourceResolver addFixedVersionStrategy(String version, String... pathPatterns) { 128 List<String> patternsList = Arrays.asList(pathPatterns); 129 List<String> prefixedPatterns = new ArrayList<>(pathPatterns.length); 130 String versionPrefix = "/" + version; 131 for (String pattern : patternsList) { 132 prefixedPatterns.add(pattern); 133 if (!pattern.startsWith(versionPrefix) && !patternsList.contains(versionPrefix + pattern)) { 134 prefixedPatterns.add(versionPrefix + pattern); 135 } 136 } 137 return addVersionStrategy(new FixedVersionStrategy(version), StringUtils.toStringArray(prefixedPatterns)); 138 } 139 140 /** 141 * Register a custom VersionStrategy to apply to resource URLs that match the 142 * given path patterns. 143 * @param strategy the custom strategy 144 * @param pathPatterns one or more resource URL path patterns, 145 * relative to the pattern configured with the resource handler 146 * @return the current instance for chained method invocation 147 * @see VersionStrategy 148 */ 149 public VersionResourceResolver addVersionStrategy(VersionStrategy strategy, String... pathPatterns) { 150 for (String pattern : pathPatterns) { 151 getStrategyMap().put(pattern, strategy); 152 } 153 return this; 154 } 155 156 157 @Override 158 protected Mono<Resource> resolveResourceInternal(@Nullable ServerWebExchange exchange, 159 String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) { 160 161 return chain.resolveResource(exchange, requestPath, locations) 162 .switchIfEmpty(Mono.defer(() -> 163 resolveVersionedResource(exchange, requestPath, locations, chain))); 164 } 165 166 private Mono<Resource> resolveVersionedResource(@Nullable ServerWebExchange exchange, 167 String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) { 168 169 VersionStrategy versionStrategy = getStrategyForPath(requestPath); 170 if (versionStrategy == null) { 171 return Mono.empty(); 172 } 173 174 String candidate = versionStrategy.extractVersion(requestPath); 175 if (!StringUtils.hasLength(candidate)) { 176 return Mono.empty(); 177 } 178 179 String simplePath = versionStrategy.removeVersion(requestPath, candidate); 180 return chain.resolveResource(exchange, simplePath, locations) 181 .filterWhen(resource -> versionStrategy.getResourceVersion(resource) 182 .map(actual -> { 183 if (candidate.equals(actual)) { 184 return true; 185 } 186 else { 187 if (logger.isTraceEnabled()) { 188 String logPrefix = exchange != null ? exchange.getLogPrefix() : ""; 189 logger.trace(logPrefix + "Found resource for \"" + requestPath + 190 "\", but version [" + candidate + "] does not match"); 191 } 192 return false; 193 } 194 })) 195 .map(resource -> new FileNameVersionedResource(resource, candidate)); 196 } 197 198 @Override 199 protected Mono<String> resolveUrlPathInternal(String resourceUrlPath, 200 List<? extends Resource> locations, ResourceResolverChain chain) { 201 202 return chain.resolveUrlPath(resourceUrlPath, locations) 203 .flatMap(baseUrl -> { 204 if (StringUtils.hasText(baseUrl)) { 205 VersionStrategy strategy = getStrategyForPath(resourceUrlPath); 206 if (strategy == null) { 207 return Mono.just(baseUrl); 208 } 209 return chain.resolveResource(null, baseUrl, locations) 210 .flatMap(resource -> strategy.getResourceVersion(resource) 211 .map(version -> strategy.addVersion(baseUrl, version))); 212 } 213 return Mono.empty(); 214 }); 215 } 216 217 /** 218 * Find a {@code VersionStrategy} for the request path of the requested resource. 219 * @return an instance of a {@code VersionStrategy} or null if none matches that request path 220 */ 221 @Nullable 222 protected VersionStrategy getStrategyForPath(String requestPath) { 223 String path = "/".concat(requestPath); 224 List<String> matchingPatterns = new ArrayList<>(); 225 for (String pattern : this.versionStrategyMap.keySet()) { 226 if (this.pathMatcher.match(pattern, path)) { 227 matchingPatterns.add(pattern); 228 } 229 } 230 if (!matchingPatterns.isEmpty()) { 231 Comparator<String> comparator = this.pathMatcher.getPatternComparator(path); 232 matchingPatterns.sort(comparator); 233 return this.versionStrategyMap.get(matchingPatterns.get(0)); 234 } 235 return null; 236 } 237 238 239 private class FileNameVersionedResource extends AbstractResource implements HttpResource { 240 241 private final Resource original; 242 243 private final String version; 244 245 public FileNameVersionedResource(Resource original, String version) { 246 this.original = original; 247 this.version = version; 248 } 249 250 @Override 251 public boolean exists() { 252 return this.original.exists(); 253 } 254 255 @Override 256 public boolean isReadable() { 257 return this.original.isReadable(); 258 } 259 260 @Override 261 public boolean isOpen() { 262 return this.original.isOpen(); 263 } 264 265 @Override 266 public boolean isFile() { 267 return this.original.isFile(); 268 } 269 270 @Override 271 public URL getURL() throws IOException { 272 return this.original.getURL(); 273 } 274 275 @Override 276 public URI getURI() throws IOException { 277 return this.original.getURI(); 278 } 279 280 @Override 281 public File getFile() throws IOException { 282 return this.original.getFile(); 283 } 284 285 @Override 286 @Nullable 287 public String getFilename() { 288 return this.original.getFilename(); 289 } 290 291 @Override 292 public long contentLength() throws IOException { 293 return this.original.contentLength(); 294 } 295 296 @Override 297 public long lastModified() throws IOException { 298 return this.original.lastModified(); 299 } 300 301 @Override 302 public Resource createRelative(String relativePath) throws IOException { 303 return this.original.createRelative(relativePath); 304 } 305 306 @Override 307 public String getDescription() { 308 return this.original.getDescription(); 309 } 310 311 @Override 312 public InputStream getInputStream() throws IOException { 313 return this.original.getInputStream(); 314 } 315 316 @Override 317 public HttpHeaders getResponseHeaders() { 318 HttpHeaders headers = (this.original instanceof HttpResource ? 319 ((HttpResource) this.original).getResponseHeaders() : new HttpHeaders()); 320 headers.setETag("\"" + this.version + "\""); 321 return headers; 322 } 323 } 324 325}