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.Collections;
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.http.server.reactive.ServerHttpRequest;
037import org.springframework.lang.Nullable;
038import org.springframework.util.Assert;
039import org.springframework.web.server.ServerWebExchange;
040
041/**
042 * Resolver that delegates to the chain, and if a resource is found, it then
043 * attempts to find an encoded (e.g. gzip, brotli) variant that is acceptable
044 * based on the "Accept-Encoding" request header.
045 *
046 * <p>The list of supported {@link #setContentCodings(List) contentCodings} can
047 * be configured, in order of preference, and each coding must be associated
048 * with {@link #setExtensions(Map) extensions}.
049 *
050 * <p>Note that this resolver must be ordered ahead of a
051 * {@link VersionResourceResolver} with a content-based, version strategy to
052 * ensure the version calculation is not impacted by the encoding.
053 *
054 * @author Rossen Stoyanchev
055 * @since 5.1
056 */
057public class EncodedResourceResolver extends AbstractResourceResolver {
058
059        /**
060         * The default content codings.
061         */
062        public static final List<String> DEFAULT_CODINGS = Arrays.asList("br", "gzip");
063
064
065        private final List<String> contentCodings = new ArrayList<>(DEFAULT_CODINGS);
066
067        private final Map<String, String> extensions = new LinkedHashMap<>();
068
069
070        public EncodedResourceResolver() {
071                this.extensions.put("gzip", ".gz");
072                this.extensions.put("br", ".br");
073        }
074
075
076        /**
077         * Configure the supported content codings in order of preference. The first
078         * coding that is present in the {@literal "Accept-Encoding"} header for a
079         * given request, and that has a file present with the associated extension,
080         * is used.
081         * <p><strong>Note:</strong> Each coding must be associated with a file
082         * extension via {@link #registerExtension} or {@link #setExtensions}. Also
083         * customizations to the list of codings here should be matched by
084         * customizations to the same list in {@link CachingResourceResolver} to
085         * ensure encoded variants of a resource are cached under separate keys.
086         * <p>By default this property is set to {@literal ["br", "gzip"]}.
087         * @param codings one or more supported content codings
088         */
089        public void setContentCodings(List<String> codings) {
090                Assert.notEmpty(codings, "At least one content coding expected");
091                this.contentCodings.clear();
092                this.contentCodings.addAll(codings);
093        }
094
095        /**
096         * Return a read-only list with the supported content codings.
097         */
098        public List<String> getContentCodings() {
099                return Collections.unmodifiableList(this.contentCodings);
100        }
101
102        /**
103         * Configure mappings from content codings to file extensions. A dot "."
104         * will be prepended in front of the extension value if not present.
105         * <p>By default this is configured with {@literal ["br" -> ".br"]} and
106         * {@literal ["gzip" -> ".gz"]}.
107         * @param extensions the extensions to use.
108         * @see #registerExtension(String, String)
109         */
110        public void setExtensions(Map<String, String> extensions) {
111                extensions.forEach(this::registerExtension);
112        }
113
114        /**
115         * Return a read-only map with coding-to-extension mappings.
116         */
117        public Map<String, String> getExtensions() {
118                return Collections.unmodifiableMap(this.extensions);
119        }
120
121        /**
122         * Java config friendly alternative to {@link #setExtensions(Map)}.
123         * @param coding the content coding
124         * @param extension the associated file extension
125         */
126        public void registerExtension(String coding, String extension) {
127                this.extensions.put(coding, (extension.startsWith(".") ? extension : "." + extension));
128        }
129
130
131        @Override
132        protected Mono<Resource> resolveResourceInternal(@Nullable ServerWebExchange exchange,
133                        String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) {
134
135                return chain.resolveResource(exchange, requestPath, locations).map(resource -> {
136
137                        if (exchange == null) {
138                                return resource;
139                        }
140
141                        String acceptEncoding = getAcceptEncoding(exchange);
142                        if (acceptEncoding == null) {
143                                return resource;
144                        }
145
146                        for (String coding : this.contentCodings) {
147                                if (acceptEncoding.contains(coding)) {
148                                        try {
149                                                String extension = getExtension(coding);
150                                                Resource encoded = new EncodedResource(resource, coding, extension);
151                                                if (encoded.exists()) {
152                                                        return encoded;
153                                                }
154                                        }
155                                        catch (IOException ex) {
156                                                logger.trace(exchange.getLogPrefix() +
157                                                                "No " + coding + " resource for [" + resource.getFilename() + "]", ex);
158                                        }
159                                }
160                        }
161
162                        return resource;
163                });
164        }
165
166        @Nullable
167        private String getAcceptEncoding(ServerWebExchange exchange) {
168                ServerHttpRequest request = exchange.getRequest();
169                String header = request.getHeaders().getFirst(HttpHeaders.ACCEPT_ENCODING);
170                return (header != null ? header.toLowerCase() : null);
171        }
172
173        private String getExtension(String coding) {
174                String extension = this.extensions.get(coding);
175                if (extension == null) {
176                        throw new IllegalStateException("No file extension associated with content coding " + coding);
177                }
178                return extension;
179        }
180
181        @Override
182        protected Mono<String> resolveUrlPathInternal(String resourceUrlPath,
183                        List<? extends Resource> locations, ResourceResolverChain chain) {
184
185                return chain.resolveUrlPath(resourceUrlPath, locations);
186        }
187
188
189        /**
190         * An encoded {@link HttpResource}.
191         */
192        static final class EncodedResource extends AbstractResource implements HttpResource {
193
194                private final Resource original;
195
196                private final String coding;
197
198                private final Resource encoded;
199
200                EncodedResource(Resource original, String coding, String extension) throws IOException {
201                        this.original = original;
202                        this.coding = coding;
203                        this.encoded = original.createRelative(original.getFilename() + extension);
204                }
205
206                @Override
207                public InputStream getInputStream() throws IOException {
208                        return this.encoded.getInputStream();
209                }
210
211                @Override
212                public boolean exists() {
213                        return this.encoded.exists();
214                }
215
216                @Override
217                public boolean isReadable() {
218                        return this.encoded.isReadable();
219                }
220
221                @Override
222                public boolean isOpen() {
223                        return this.encoded.isOpen();
224                }
225
226                @Override
227                public boolean isFile() {
228                        return this.encoded.isFile();
229                }
230
231                @Override
232                public URL getURL() throws IOException {
233                        return this.encoded.getURL();
234                }
235
236                @Override
237                public URI getURI() throws IOException {
238                        return this.encoded.getURI();
239                }
240
241                @Override
242                public File getFile() throws IOException {
243                        return this.encoded.getFile();
244                }
245
246                @Override
247                public long contentLength() throws IOException {
248                        return this.encoded.contentLength();
249                }
250
251                @Override
252                public long lastModified() throws IOException {
253                        return this.encoded.lastModified();
254                }
255
256                @Override
257                public Resource createRelative(String relativePath) throws IOException {
258                        return this.encoded.createRelative(relativePath);
259                }
260
261                @Override
262                @Nullable
263                public String getFilename() {
264                        return this.original.getFilename();
265                }
266
267                @Override
268                public String getDescription() {
269                        return this.encoded.getDescription();
270                }
271
272                @Override
273                public HttpHeaders getResponseHeaders() {
274                        HttpHeaders headers;
275                        if (this.original instanceof HttpResource) {
276                                headers = ((HttpResource) this.original).getResponseHeaders();
277                        }
278                        else {
279                                headers = new HttpHeaders();
280                        }
281                        headers.add(HttpHeaders.CONTENT_ENCODING, this.coding);
282                        headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_ENCODING);
283                        return headers;
284                }
285        }
286
287}