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}