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}