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