diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java index 4d5695d76de..8261bafe208 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/CssLinkResourceTransformer.java @@ -70,6 +70,7 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport { } + @SuppressWarnings("deprecation") @Override public Mono transform(ServerWebExchange exchange, Resource inputResource, ResourceTransformerChain transformerChain) { @@ -78,6 +79,7 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport { .flatMap(ouptputResource -> { String filename = ouptputResource.getFilename(); if (!"css".equals(StringUtils.getFilenameExtension(filename)) || + inputResource instanceof EncodedResourceResolver.EncodedResource || inputResource instanceof GzipResourceResolver.GzippedResource) { return Mono.just(ouptputResource); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java new file mode 100644 index 00000000000..a1eb0a12f30 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java @@ -0,0 +1,275 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.reactive.resource; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.core.io.AbstractResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolver that delegates to the chain, and if a resource is found, it then + * attempts to find an encoded (e.g. gzip, brotli) variant that is acceptable + * based on the "Accept-Encoding" request header. + * + *

The list of supported {@link #setContentCodings(List) contentCodings} can + * be configured, in order of preference, and each coding must be associated + * with {@link #setExtensions(Map) extensions}. + * + *

Note that this resolver must be ordered ahead of a + * {@link VersionResourceResolver} with a content-based, version strategy to + * ensure the version calculation is not impacted by the encoding. + * + * @author Rossen Stoyanchev + * @since 5.1 + */ +public class EncodedResourceResolver extends AbstractResourceResolver { + + private final List contentCodings = new ArrayList<>(Arrays.asList("br", "gzip")); + + private final Map extensions = new LinkedHashMap<>(); + + + public EncodedResourceResolver() { + this.extensions.put("gzip", ".gz"); + this.extensions.put("br", ".br"); + } + + + /** + * Configure the supported content codings in order of preference. The first + * coding that is present in the {@literal "Accept-Encoding"} header for a + * given request, and that has a file present with the associated extension, + * is used. + * + *

Note: Each coding must be associated with a file + * extension via {@link #registerExtension} or {@link #setExtensions}. + * + *

By default this property is set to {@literal ["br", "gzip"]}. + * + * @param codings one or more supported content codings + */ + public void setContentCodings(List codings) { + Assert.notEmpty(codings, "At least one content coding expected."); + this.contentCodings.clear(); + this.contentCodings.addAll(codings); + } + + /** + * Return a read-only list with the supported content codings. + */ + public List getContentCodings() { + return Collections.unmodifiableList(this.contentCodings); + } + + /** + * Configure mappings from content codings to file extensions. A dot "." + * will be prepended in front of the extension value if not present. + *

By default this is configured with {@literal ["br" -> ".br"]} and + * {@literal ["gzip" -> ".gz"]}. + * @param extensions the extensions to use. + * @see #registerExtension(String, String) + */ + public void setExtensions(Map extensions) { + extensions.forEach(this::registerExtension); + } + + /** + * Java config friendly alternative to {@link #setExtensions(Map)}. + * @param coding the content coding + * @param extension the associated file extension + */ + public void registerExtension(String coding, String extension) { + this.extensions.put(coding, extension.startsWith(".") ? extension : "." + extension); + } + + /** + * Return a read-only map with coding-to-extension mappings. + */ + public Map getExtensions() { + return Collections.unmodifiableMap(this.extensions); + } + + + @Override + protected Mono resolveResourceInternal(@Nullable ServerWebExchange exchange, + String requestPath, List locations, ResourceResolverChain chain) { + + return chain.resolveResource(exchange, requestPath, locations).map(resource -> { + + if (exchange == null) { + return resource; + } + + String acceptEncoding = getAcceptEncoding(exchange); + if (acceptEncoding == null) { + return resource; + } + + for (String coding : this.contentCodings) { + if (acceptEncoding.contains(coding)) { + try { + String extension = getExtension(coding); + Resource encoded = new EncodedResource(resource, coding, extension); + if (encoded.exists()) { + return encoded; + } + } + catch (IOException ex) { + logger.trace("No " + coding + " resource for [" + resource.getFilename() + "]", ex); + } + } + } + + return resource; + }); + } + + @Nullable + private String getAcceptEncoding(ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + String header = request.getHeaders().getFirst(HttpHeaders.ACCEPT_ENCODING); + return header != null ? header.toLowerCase() : null; + } + + private String getExtension(String coding) { + String extension = this.extensions.get(coding); + Assert.notNull(extension, "No file extension associated with content coding " + coding); + return extension; + } + + @Override + protected Mono resolveUrlPathInternal(String resourceUrlPath, + List locations, ResourceResolverChain chain) { + + return chain.resolveUrlPath(resourceUrlPath, locations); + } + + + static final class EncodedResource extends AbstractResource implements HttpResource { + + private final Resource original; + + private final String coding; + + private final Resource encoded; + + + EncodedResource(Resource original, String coding, String extension) throws IOException { + this.original = original; + this.coding = coding; + this.encoded = original.createRelative(original.getFilename() + extension); + } + + @Override + public InputStream getInputStream() throws IOException { + return this.encoded.getInputStream(); + } + + @Override + public boolean exists() { + return this.encoded.exists(); + } + + @Override + public boolean isReadable() { + return this.encoded.isReadable(); + } + + @Override + public boolean isOpen() { + return this.encoded.isOpen(); + } + + @Override + public boolean isFile() { + return this.encoded.isFile(); + } + + @Override + public URL getURL() throws IOException { + return this.encoded.getURL(); + } + + @Override + public URI getURI() throws IOException { + return this.encoded.getURI(); + } + + @Override + public File getFile() throws IOException { + return this.encoded.getFile(); + } + + @Override + public long contentLength() throws IOException { + return this.encoded.contentLength(); + } + + @Override + public long lastModified() throws IOException { + return this.encoded.lastModified(); + } + + @Override + public Resource createRelative(String relativePath) throws IOException { + return this.encoded.createRelative(relativePath); + } + + @Override + @Nullable + public String getFilename() { + return this.original.getFilename(); + } + + @Override + public String getDescription() { + return this.encoded.getDescription(); + } + + @Override + public HttpHeaders getResponseHeaders() { + HttpHeaders headers; + if (this.original instanceof HttpResource) { + headers = ((HttpResource) this.original).getResponseHeaders(); + } + else { + headers = new HttpHeaders(); + } + headers.add(HttpHeaders.CONTENT_ENCODING, this.coding); + return headers; + } + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/GzipResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/GzipResourceResolver.java index 423e1d15651..9a11fd2844b 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/GzipResourceResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/GzipResourceResolver.java @@ -40,7 +40,9 @@ import org.springframework.web.server.ServerWebExchange; * * @author Rossen Stoyanchev * @since 5.0 + * @deprecated as of 5.1 in favor of using {@link EncodedResourceResolver}. */ +@Deprecated public class GzipResourceResolver extends AbstractResourceResolver { @Override diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkResourceTransformerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkResourceTransformerTests.java index 7fe1415b6f2..92a15eba81f 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkResourceTransformerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/CssLinkResourceTransformerTests.java @@ -145,7 +145,8 @@ public class CssLinkResourceTransformerTests { MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/static/main.css")); Resource original = new ClassPathResource("test/main.css", getClass()); createTempCopy("main.css", "main.css.gz"); - GzipResourceResolver.GzippedResource expected = new GzipResourceResolver.GzippedResource(original); + EncodedResourceResolver.EncodedResource expected = + new EncodedResourceResolver.EncodedResource(original, "gzip", ".gz"); StepVerifier.create(this.transformerChain.transform(exchange, expected)) .expectNext(expected) .expectComplete().verify(); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/EncodedResourceResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/EncodedResourceResolverTests.java new file mode 100644 index 00000000000..80ec4b8ba04 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/EncodedResourceResolverTests.java @@ -0,0 +1,172 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.reactive.resource; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPOutputStream; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Ignore; +import org.junit.Test; + +import org.springframework.cache.Cache; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; +import org.springframework.mock.web.test.server.MockServerWebExchange; +import org.springframework.util.FileCopyUtils; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link EncodedResourceResolver}. + * + * @author Rossen Stoyanchev + */ +public class EncodedResourceResolverTests { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + + private ResourceResolverChain resolver; + + private List locations; + + + @BeforeClass + public static void createGzippedResources() throws IOException { + createGzFile("/js/foo.js"); + createGzFile("foo.css"); + } + + private static void createGzFile(String filePath) throws IOException { + Resource location = new ClassPathResource("test/", EncodedResourceResolverTests.class); + Resource resource = new FileSystemResource(location.createRelative(filePath).getFile()); + + Path gzFilePath = Paths.get(resource.getFile().getAbsolutePath() + ".gz"); + Files.deleteIfExists(gzFilePath); + + File gzFile = Files.createFile(gzFilePath).toFile(); + GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(gzFile)); + FileCopyUtils.copy(resource.getInputStream(), out); + gzFile.deleteOnExit(); + } + + + @Before + public void setup() { + Cache cache = new ConcurrentMapCache("resourceCache"); + + VersionResourceResolver versionResolver = new VersionResourceResolver(); + versionResolver.setStrategyMap(Collections.singletonMap("/**", new ContentVersionStrategy())); + + List resolvers = new ArrayList<>(); + resolvers.add(new CachingResourceResolver(cache)); + resolvers.add(new EncodedResourceResolver()); + resolvers.add(versionResolver); + resolvers.add(new PathResourceResolver()); + this.resolver = new DefaultResourceResolverChain(resolvers); + + this.locations = new ArrayList<>(); + this.locations.add(new ClassPathResource("test/", getClass())); + this.locations.add(new ClassPathResource("testalternatepath/", getClass())); + } + + + @Test + public void resolveGzipped() { + + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("").header("Accept-Encoding", "gzip")); + + String file = "js/foo.js"; + Resource actual = this.resolver.resolveResource(exchange, file, this.locations).block(TIMEOUT); + + assertEquals(getResource(file + ".gz").getDescription(), actual.getDescription()); + assertEquals(getResource(file).getFilename(), actual.getFilename()); + assertTrue(actual instanceof HttpResource); + } + + @Test + @Ignore // SPR-16862 + public void resolveGzippedWithVersion() { + + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("").header("Accept-Encoding", "gzip")); + + String file = "foo-e36d2e05253c6c7085a91522ce43a0b4.css"; + Resource actual = this.resolver.resolveResource(exchange, file, this.locations).block(TIMEOUT); + + assertEquals(getResource("foo.css.gz").getDescription(), actual.getDescription()); + assertEquals(getResource("foo.css").getFilename(), actual.getFilename()); + assertTrue(actual instanceof HttpResource); + } + + @Test + public void resolveFromCacheWithEncodingVariants() { + + // 1. Resolve, and cache .gz variant + + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("").header("Accept-Encoding", "gzip")); + + String file = "js/foo.js"; + Resource resolved = this.resolver.resolveResource(exchange, file, this.locations).block(TIMEOUT); + + assertEquals(getResource(file + ".gz").getDescription(), resolved.getDescription()); + assertEquals(getResource(file).getFilename(), resolved.getFilename()); + assertTrue(resolved instanceof HttpResource); + + // 2. Resolve unencoded resource + + exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/js/foo.js")); + resolved = this.resolver.resolveResource(exchange, file, this.locations).block(TIMEOUT); + + assertEquals(getResource(file).getDescription(), resolved.getDescription()); + assertEquals(getResource(file).getFilename(), resolved.getFilename()); + assertFalse(resolved instanceof HttpResource); + } + + @Test // SPR-13149 + public void resolveWithNullRequest() { + + String file = "js/foo.js"; + Resource resolved = this.resolver.resolveResource(null, file, this.locations).block(TIMEOUT); + + assertEquals(getResource(file).getDescription(), resolved.getDescription()); + assertEquals(getResource(file).getFilename(), resolved.getFilename()); + } + + private Resource getResource(String filePath) { + return new ClassPathResource("test/" + filePath, getClass()); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/GzipResourceResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/GzipResourceResolverTests.java deleted file mode 100644 index 85cb2a351d4..00000000000 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/GzipResourceResolverTests.java +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright 2002-2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.web.reactive.resource; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.zip.GZIPOutputStream; - -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; - -import org.springframework.cache.Cache; -import org.springframework.cache.concurrent.ConcurrentMapCache; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; -import org.springframework.mock.web.test.server.MockServerWebExchange; -import org.springframework.util.FileCopyUtils; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - - -/** - * Unit tests for {@link GzipResourceResolver}. - * - * @author Rossen Stoyanchev - */ -public class GzipResourceResolverTests { - - private static final Duration TIMEOUT = Duration.ofSeconds(5); - - - private ResourceResolverChain resolver; - - private List locations; - - - @BeforeClass - public static void createGzippedResources() throws IOException { - createGzFile("/js/foo.js"); - createGzFile("foo-e36d2e05253c6c7085a91522ce43a0b4.css"); - } - - private static void createGzFile(String filePath) throws IOException { - Resource location = new ClassPathResource("test/", GzipResourceResolverTests.class); - Resource fileResource = new FileSystemResource(location.createRelative(filePath).getFile()); - Path gzFilePath = Paths.get(fileResource.getFile().getAbsolutePath() + ".gz"); - Files.deleteIfExists(gzFilePath); - File gzFile = Files.createFile(gzFilePath).toFile(); - GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(gzFile)); - FileCopyUtils.copy(fileResource.getInputStream(), out); - gzFile.deleteOnExit(); - } - - - @Before - public void setup() { - Cache cache = new ConcurrentMapCache("resourceCache"); - - Map versionStrategyMap = new HashMap<>(); - versionStrategyMap.put("/**", new ContentVersionStrategy()); - VersionResourceResolver versionResolver = new VersionResourceResolver(); - versionResolver.setStrategyMap(versionStrategyMap); - - List resolvers = new ArrayList<>(); - resolvers.add(new CachingResourceResolver(cache)); - resolvers.add(new GzipResourceResolver()); - resolvers.add(versionResolver); - resolvers.add(new PathResourceResolver()); - this.resolver = new DefaultResourceResolverChain(resolvers); - - this.locations = new ArrayList<>(); - this.locations.add(new ClassPathResource("test/", getClass())); - this.locations.add(new ClassPathResource("testalternatepath/", getClass())); - } - - - @Test - public void resolveGzippedFile() throws IOException { - MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("") - .header("Accept-Encoding", "gzip")); - - String file = "js/foo.js"; - Resource resolved = this.resolver.resolveResource(exchange, file, this.locations).block(TIMEOUT); - - String gzFile = file+".gz"; - Resource resource = new ClassPathResource("test/" + gzFile, getClass()); - assertEquals(resource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/" + file).getFilename(), resolved.getFilename()); - assertTrue("Expected " + resolved + " to be of type " + HttpResource.class, - resolved instanceof HttpResource); - } - - @Test - public void resolveFingerprintedGzippedFile() throws IOException { - MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("") - .header("Accept-Encoding", "gzip")); - - String file = "foo-e36d2e05253c6c7085a91522ce43a0b4.css"; - Resource resolved = this.resolver.resolveResource(exchange, file, this.locations).block(TIMEOUT); - - String gzFile = file + ".gz"; - Resource resource = new ClassPathResource("test/" + gzFile, getClass()); - assertEquals(resource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/"+file).getFilename(), resolved.getFilename()); - assertTrue("Expected " + resolved + " to be of type " + HttpResource.class, - resolved instanceof HttpResource); - } - - @Test - public void resolveFromCacheWithEncodingVariants() throws IOException { - MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("") - .header("Accept-Encoding", "gzip")); - - String file = "js/foo.js"; - Resource resolved = this.resolver.resolveResource(exchange, file, this.locations).block(TIMEOUT); - - String gzFile = file+".gz"; - Resource gzResource = new ClassPathResource("test/"+gzFile, getClass()); - assertEquals(gzResource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/" + file).getFilename(), resolved.getFilename()); - assertTrue("Expected " + resolved + " to be of type " + HttpResource.class, - resolved instanceof HttpResource); - - // resolved resource is now cached in CachingResourceResolver - - exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/js/foo.js")); - resolved = this.resolver.resolveResource(exchange, file, this.locations).block(TIMEOUT); - - Resource resource = new ClassPathResource("test/"+file, getClass()); - assertEquals(resource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/" + file).getFilename(), resolved.getFilename()); - assertFalse("Expected " + resolved + " to *not* be of type " + HttpResource.class, - resolved instanceof HttpResource); - } - - @Test // SPR-13149 - public void resolveWithNullRequest() throws IOException { - String file = "js/foo.js"; - Resource resolved = this.resolver.resolveResource(null, file, this.locations).block(TIMEOUT); - - String gzFile = file+".gz"; - Resource gzResource = new ClassPathResource("test/" + gzFile, getClass()); - assertEquals(gzResource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/" + file).getFilename(), resolved.getFilename()); - assertTrue("Expected " + resolved + " to be of type " + HttpResource.class, - resolved instanceof HttpResource); - } - -} diff --git a/spring-webflux/src/test/resources/org/springframework/web/reactive/resource/test/foo-e36d2e05253c6c7085a91522ce43a0b4.css b/spring-webflux/src/test/resources/org/springframework/web/reactive/resource/test/foo-e36d2e05253c6c7085a91522ce43a0b4.css deleted file mode 100644 index e2f0b1c742a..00000000000 --- a/spring-webflux/src/test/resources/org/springframework/web/reactive/resource/test/foo-e36d2e05253c6c7085a91522ce43a0b4.css +++ /dev/null @@ -1 +0,0 @@ -h1 { color:red; } \ No newline at end of file diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkResourceTransformer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkResourceTransformer.java index 38a1e7da468..5b243490607 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkResourceTransformer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/CssLinkResourceTransformer.java @@ -62,6 +62,7 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport { } + @SuppressWarnings("deprecation") @Override public Resource transform(HttpServletRequest request, Resource resource, ResourceTransformerChain transformerChain) throws IOException { @@ -70,6 +71,7 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport { String filename = resource.getFilename(); if (!"css".equals(StringUtils.getFilenameExtension(filename)) || + resource instanceof EncodedResourceResolver.EncodedResource || resource instanceof GzipResourceResolver.GzippedResource) { return resource; } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/EncodedResourceResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/EncodedResourceResolver.java new file mode 100644 index 00000000000..f87cfefe2e3 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/EncodedResourceResolver.java @@ -0,0 +1,270 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.servlet.resource; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.core.io.AbstractResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Resolver that delegates to the chain, and if a resource is found, it then + * attempts to find an encoded (e.g. gzip, brotli) variant that is acceptable + * based on the "Accept-Encoding" request header. + * + *

The list of supported {@link #setContentCodings(List) contentCodings} can + * be configured, in order of preference, and each coding must be associated + * with {@link #setExtensions(Map) extensions}. + * + *

Note that this resolver must be ordered ahead of a + * {@link VersionResourceResolver} with a content-based, version strategy to + * ensure the version calculation is not impacted by the encoding. + * + * @author Rossen Stoyanchev + * @since 5.1 + */ +public class EncodedResourceResolver extends AbstractResourceResolver { + + private final List contentCodings = new ArrayList<>(Arrays.asList("br", "gzip")); + + private final Map extensions = new LinkedHashMap<>(); + + + public EncodedResourceResolver() { + this.extensions.put("gzip", ".gz"); + this.extensions.put("br", ".br"); + } + + + /** + * Configure the supported content codings in order of preference. The first + * coding that is present in the {@literal "Accept-Encoding"} header for a + * given request, and that has a file present with the associated extension, + * is used. + * + *

Note: Each coding must be associated with a file + * extension via {@link #registerExtension} or {@link #setExtensions}. + * + *

By default this property is set to {@literal ["br", "gzip"]}. + * + * @param codings one or more supported content codings + */ + public void setContentCodings(List codings) { + Assert.notEmpty(codings, "At least one content coding expected."); + this.contentCodings.clear(); + this.contentCodings.addAll(codings); + } + + /** + * Return a read-only list with the supported content codings. + */ + public List getContentCodings() { + return Collections.unmodifiableList(this.contentCodings); + } + + /** + * Configure mappings from content codings to file extensions. A dot "." + * will be prepended in front of the extension value if not present. + *

By default this is configured with {@literal ["br" -> ".br"]} and + * {@literal ["gzip" -> ".gz"]}. + * @param extensions the extensions to use. + * @see #registerExtension(String, String) + */ + public void setExtensions(Map extensions) { + extensions.forEach(this::registerExtension); + } + + /** + * Java config friendly alternative to {@link #setExtensions(Map)}. + * @param coding the content coding + * @param extension the associated file extension + */ + public void registerExtension(String coding, String extension) { + this.extensions.put(coding, extension.startsWith(".") ? extension : "." + extension); + } + + /** + * Return a read-only map with coding-to-extension mappings. + */ + public Map getExtensions() { + return Collections.unmodifiableMap(this.extensions); + } + + + @Override + protected Resource resolveResourceInternal(@Nullable HttpServletRequest request, String requestPath, + List locations, ResourceResolverChain chain) { + + Resource resource = chain.resolveResource(request, requestPath, locations); + if (resource == null || request == null) { + return resource; + } + + String acceptEncoding = getAcceptEncoding(request); + if (acceptEncoding == null) { + return resource; + } + + for (String coding : this.contentCodings) { + if (acceptEncoding.contains(coding)) { + try { + String extension = getExtension(coding); + Resource encoded = new EncodedResource(resource, coding, extension); + if (encoded.exists()) { + return encoded; + } + } + catch (IOException ex) { + logger.trace("No " + coding + " resource for [" + resource.getFilename() + "]", ex); + } + } + } + + return resource; + } + + @Nullable + private String getAcceptEncoding(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.ACCEPT_ENCODING); + return header != null ? header.toLowerCase() : null; + } + + private String getExtension(String coding) { + String extension = this.extensions.get(coding); + Assert.notNull(extension, "No file extension associated with content coding " + coding); + return extension; + } + + @Override + protected String resolveUrlPathInternal(String resourceUrlPath, + List locations, ResourceResolverChain chain) { + + return chain.resolveUrlPath(resourceUrlPath, locations); + } + + + static final class EncodedResource extends AbstractResource implements HttpResource { + + private final Resource original; + + private final String coding; + + private final Resource encoded; + + + EncodedResource(Resource original, String coding, String extension) throws IOException { + this.original = original; + this.coding = coding; + this.encoded = original.createRelative(original.getFilename() + extension); + } + + + @Override + public InputStream getInputStream() throws IOException { + return this.encoded.getInputStream(); + } + + @Override + public boolean exists() { + return this.encoded.exists(); + } + + @Override + public boolean isReadable() { + return this.encoded.isReadable(); + } + + @Override + public boolean isOpen() { + return this.encoded.isOpen(); + } + + @Override + public boolean isFile() { + return this.encoded.isFile(); + } + + @Override + public URL getURL() throws IOException { + return this.encoded.getURL(); + } + + @Override + public URI getURI() throws IOException { + return this.encoded.getURI(); + } + + @Override + public File getFile() throws IOException { + return this.encoded.getFile(); + } + + @Override + public long contentLength() throws IOException { + return this.encoded.contentLength(); + } + + @Override + public long lastModified() throws IOException { + return this.encoded.lastModified(); + } + + @Override + public Resource createRelative(String relativePath) throws IOException { + return this.encoded.createRelative(relativePath); + } + + @Override + @Nullable + public String getFilename() { + return this.original.getFilename(); + } + + @Override + public String getDescription() { + return this.encoded.getDescription(); + } + + @Override + public HttpHeaders getResponseHeaders() { + HttpHeaders headers; + if (this.original instanceof HttpResource) { + headers = ((HttpResource) this.original).getResponseHeaders(); + } + else { + headers = new HttpHeaders(); + } + headers.add(HttpHeaders.CONTENT_ENCODING, this.coding); + return headers; + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/GzipResourceResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/GzipResourceResolver.java index 85257bf6859..640fc1dbeda 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/GzipResourceResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/GzipResourceResolver.java @@ -40,7 +40,9 @@ import org.springframework.lang.Nullable; * @author Rossen Stoyanchev * @author Sam Brannen * @since 4.1 + * @deprecated as of 5.1 in favor of using {@link EncodedResourceResolver}. */ +@Deprecated public class GzipResourceResolver extends AbstractResourceResolver { @Override diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java index c3ca056928e..cf3b2a5200d 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java @@ -112,8 +112,8 @@ import org.springframework.web.servlet.resource.CachingResourceTransformer; import org.springframework.web.servlet.resource.ContentVersionStrategy; import org.springframework.web.servlet.resource.CssLinkResourceTransformer; import org.springframework.web.servlet.resource.DefaultServletHttpRequestHandler; +import org.springframework.web.servlet.resource.EncodedResourceResolver; import org.springframework.web.servlet.resource.FixedVersionStrategy; -import org.springframework.web.servlet.resource.GzipResourceResolver; import org.springframework.web.servlet.resource.PathResourceResolver; import org.springframework.web.servlet.resource.ResourceHttpRequestHandler; import org.springframework.web.servlet.resource.ResourceResolver; @@ -140,16 +140,8 @@ import org.springframework.web.servlet.view.tiles3.TilesConfigurer; import org.springframework.web.servlet.view.tiles3.TilesViewResolver; import org.springframework.web.util.UrlPathHelper; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.instanceOf; -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; /** * Tests loading actual MVC namespace configuration. @@ -474,7 +466,7 @@ public class MvcNamespaceTests { List resolvers = handler.getResourceResolvers(); assertThat(resolvers, Matchers.hasSize(3)); assertThat(resolvers.get(0), Matchers.instanceOf(VersionResourceResolver.class)); - assertThat(resolvers.get(1), Matchers.instanceOf(GzipResourceResolver.class)); + assertThat(resolvers.get(1), Matchers.instanceOf(EncodedResourceResolver.class)); assertThat(resolvers.get(2), Matchers.instanceOf(PathResourceResolver.class)); VersionResourceResolver versionResolver = (VersionResourceResolver) resolvers.get(0); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java index 6b7f0b04939..3569e18180c 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/CssLinkResourceTransformerTests.java @@ -144,7 +144,8 @@ public class CssLinkResourceTransformerTests { this.request = new MockHttpServletRequest("GET", "/static/main.css"); Resource original = new ClassPathResource("test/main.css", getClass()); createTempCopy("main.css", "main.css.gz"); - GzipResourceResolver.GzippedResource expected = new GzipResourceResolver.GzippedResource(original); + EncodedResourceResolver.EncodedResource expected = + new EncodedResourceResolver.EncodedResource(original, "gzip", ".gz"); Resource actual = this.transformerChain.transform(this.request, expected); assertSame(expected, actual); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/EncodedResourceResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/EncodedResourceResolverTests.java new file mode 100644 index 00000000000..9e58470c290 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/EncodedResourceResolverTests.java @@ -0,0 +1,160 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.servlet.resource; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.zip.GZIPOutputStream; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.springframework.cache.Cache; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.mock.web.test.MockHttpServletRequest; +import org.springframework.util.FileCopyUtils; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link EncodedResourceResolver}. + * + * @author Jeremy Grelle + * @author Rossen Stoyanchev + */ +public class EncodedResourceResolverTests { + + private ResourceResolverChain resolver; + + private List locations; + + private Cache cache; + + + @BeforeClass + public static void createGzippedResources() throws IOException { + createGzipFile("/js/foo.js"); + createGzipFile("foo.css"); + } + + private static void createGzipFile(String filePath) throws IOException { + Resource location = new ClassPathResource("test/", EncodedResourceResolverTests.class); + Resource resource = new FileSystemResource(location.createRelative(filePath).getFile()); + + Path gzFilePath = Paths.get(resource.getFile().getAbsolutePath() + ".gz"); + Files.deleteIfExists(gzFilePath); + + File gzFile = Files.createFile(gzFilePath).toFile(); + GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(gzFile)); + FileCopyUtils.copy(resource.getInputStream(), out); + gzFile.deleteOnExit(); + } + + + @Before + public void setUp() { + this.cache = new ConcurrentMapCache("resourceCache"); + + VersionResourceResolver versionResolver = new VersionResourceResolver(); + versionResolver.setStrategyMap(Collections.singletonMap("/**", new ContentVersionStrategy())); + + List resolvers = new ArrayList<>(); + resolvers.add(new CachingResourceResolver(this.cache)); + resolvers.add(new EncodedResourceResolver()); + resolvers.add(versionResolver); + resolvers.add(new PathResourceResolver()); + this.resolver = new DefaultResourceResolverChain(resolvers); + + this.locations = new ArrayList<>(); + this.locations.add(new ClassPathResource("test/", getClass())); + this.locations.add(new ClassPathResource("testalternatepath/", getClass())); + } + + + @Test + public void resolveGzipped() { + String file = "js/foo.js"; + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Accept-Encoding", "gzip"); + Resource actual = this.resolver.resolveResource(request, file, this.locations); + + assertEquals(getResource(file + ".gz").getDescription(), actual.getDescription()); + assertEquals(getResource(file).getFilename(), actual.getFilename()); + assertTrue(actual instanceof HttpResource); + } + + @Test + public void resolveGzippedWithVersion() { + String file = "foo-e36d2e05253c6c7085a91522ce43a0b4.css"; + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Accept-Encoding", "gzip"); + Resource resolved = this.resolver.resolveResource(request, file, this.locations); + + assertEquals(getResource("foo.css.gz").getDescription(), resolved.getDescription()); + assertEquals(getResource("foo.css").getFilename(), resolved.getFilename()); + assertTrue(resolved instanceof HttpResource); + } + + @Test + public void resolveFromCacheWithEncodingVariants() { + + // 1. Resolve, and cache .gz variant + + String file = "js/foo.js"; + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/js/foo.js"); + request.addHeader("Accept-Encoding", "gzip"); + Resource resolved = this.resolver.resolveResource(request, file, this.locations); + + assertEquals(getResource(file + ".gz").getDescription(), resolved.getDescription()); + assertEquals(getResource(file).getFilename(), resolved.getFilename()); + assertTrue(resolved instanceof HttpResource); + + // 2. Resolve unencoded resource + + request = new MockHttpServletRequest("GET", "/js/foo.js"); + resolved = this.resolver.resolveResource(request, file, this.locations); + + assertEquals(getResource(file).getDescription(), resolved.getDescription()); + assertEquals(getResource(file).getFilename(), resolved.getFilename()); + assertFalse(resolved instanceof HttpResource); + } + + @Test // SPR-13149 + public void resolveWithNullRequest() { + String file = "js/foo.js"; + Resource resolved = this.resolver.resolveResource(null, file, this.locations); + + assertEquals(getResource(file).getDescription(), resolved.getDescription()); + assertEquals(getResource(file).getFilename(), resolved.getFilename()); + } + + private Resource getResource(String filePath) { + return new ClassPathResource("test/" + filePath, getClass()); + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/GzipResourceResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/GzipResourceResolverTests.java deleted file mode 100644 index 74b58fe1f28..00000000000 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/GzipResourceResolverTests.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright 2002-2016 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.web.servlet.resource; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.zip.GZIPOutputStream; - -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; - -import org.springframework.cache.Cache; -import org.springframework.cache.concurrent.ConcurrentMapCache; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.mock.web.test.MockHttpServletRequest; -import org.springframework.util.FileCopyUtils; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - - -/** - * Unit tests for {@link GzipResourceResolver}. - * - * @author Jeremy Grelle - * @author Rossen Stoyanchev - */ -public class GzipResourceResolverTests { - - private ResourceResolverChain resolver; - - private List locations; - - private Cache cache; - - - @BeforeClass - public static void createGzippedResources() throws IOException { - createGzFile("/js/foo.js"); - createGzFile("foo-e36d2e05253c6c7085a91522ce43a0b4.css"); - } - - private static void createGzFile(String filePath) throws IOException { - Resource location = new ClassPathResource("test/", GzipResourceResolverTests.class); - Resource fileResource = new FileSystemResource(location.createRelative(filePath).getFile()); - Path gzFilePath = Paths.get(fileResource.getFile().getAbsolutePath() + ".gz"); - Files.deleteIfExists(gzFilePath); - File gzFile = Files.createFile(gzFilePath).toFile(); - GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(gzFile)); - FileCopyUtils.copy(fileResource.getInputStream(), out); - gzFile.deleteOnExit(); - } - - - @Before - public void setUp() { - this.cache = new ConcurrentMapCache("resourceCache"); - - Map versionStrategyMap = new HashMap<>(); - versionStrategyMap.put("/**", new ContentVersionStrategy()); - VersionResourceResolver versionResolver = new VersionResourceResolver(); - versionResolver.setStrategyMap(versionStrategyMap); - - List resolvers = new ArrayList<>(); - resolvers.add(new CachingResourceResolver(this.cache)); - resolvers.add(new GzipResourceResolver()); - resolvers.add(versionResolver); - resolvers.add(new PathResourceResolver()); - this.resolver = new DefaultResourceResolverChain(resolvers); - - this.locations = new ArrayList<>(); - this.locations.add(new ClassPathResource("test/", getClass())); - this.locations.add(new ClassPathResource("testalternatepath/", getClass())); - } - - - @Test - public void resolveGzippedFile() throws IOException { - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader("Accept-Encoding", "gzip"); - String file = "js/foo.js"; - Resource resolved = this.resolver.resolveResource(request, file, this.locations); - - String gzFile = file + ".gz"; - Resource resource = new ClassPathResource("test/"+gzFile, getClass()); - assertEquals(resource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/" + file).getFilename(), resolved.getFilename()); - assertTrue("Expected " + resolved + " to be of type " + HttpResource.class, - resolved instanceof HttpResource); - } - - @Test - public void resolveFingerprintedGzippedFile() throws IOException { - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader("Accept-Encoding", "gzip"); - String file = "foo-e36d2e05253c6c7085a91522ce43a0b4.css"; - Resource resolved = this.resolver.resolveResource(request, file, this.locations); - - String gzFile = file + ".gz"; - Resource resource = new ClassPathResource("test/"+gzFile, getClass()); - assertEquals(resource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/"+file).getFilename(), resolved.getFilename()); - assertTrue("Expected " + resolved + " to be of type " + HttpResource.class, - resolved instanceof HttpResource); - } - - @Test - public void resolveFromCacheWithEncodingVariants() throws IOException { - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/js/foo.js"); - request.addHeader("Accept-Encoding", "gzip"); - String file = "js/foo.js"; - Resource resolved = this.resolver.resolveResource(request, file, this.locations); - - String gzFile = file + ".gz"; - Resource gzResource = new ClassPathResource("test/"+gzFile, getClass()); - assertEquals(gzResource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/" + file).getFilename(), resolved.getFilename()); - assertTrue("Expected " + resolved + " to be of type " + HttpResource.class, - resolved instanceof HttpResource); - - // resolved resource is now cached in CachingResourceResolver - - request = new MockHttpServletRequest("GET", "/js/foo.js"); - resolved = this.resolver.resolveResource(request, file, this.locations); - - Resource resource = new ClassPathResource("test/"+file, getClass()); - assertEquals(resource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/" + file).getFilename(), resolved.getFilename()); - assertFalse("Expected " + resolved + " to *not* be of type " + HttpResource.class, - resolved instanceof HttpResource); - } - - @Test // SPR-13149 - public void resolveWithNullRequest() throws IOException { - String file = "js/foo.js"; - Resource resolved = this.resolver.resolveResource(null, file, this.locations); - - String gzFile = file+".gz"; - Resource gzResource = new ClassPathResource("test/"+gzFile, getClass()); - assertEquals(gzResource.getDescription(), resolved.getDescription()); - assertEquals(new ClassPathResource("test/" + file).getFilename(), resolved.getFilename()); - assertTrue("Expected " + resolved + " to be of type " + HttpResource.class, - resolved instanceof HttpResource); - } - -} diff --git a/spring-webmvc/src/test/resources/org/springframework/web/servlet/config/mvc-config-resources-chain-no-auto.xml b/spring-webmvc/src/test/resources/org/springframework/web/servlet/config/mvc-config-resources-chain-no-auto.xml index 1d25d8b55b9..4f325f79263 100644 --- a/spring-webmvc/src/test/resources/org/springframework/web/servlet/config/mvc-config-resources-chain-no-auto.xml +++ b/spring-webmvc/src/test/resources/org/springframework/web/servlet/config/mvc-config-resources-chain-no-auto.xml @@ -15,7 +15,7 @@ - + @@ -27,7 +27,7 @@ - + diff --git a/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/foo-e36d2e05253c6c7085a91522ce43a0b4.css b/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/foo-e36d2e05253c6c7085a91522ce43a0b4.css deleted file mode 100644 index e2f0b1c742a..00000000000 --- a/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/foo-e36d2e05253c6c7085a91522ce43a0b4.css +++ /dev/null @@ -1 +0,0 @@ -h1 { color:red; } \ No newline at end of file diff --git a/src/docs/asciidoc/web/webflux.adoc b/src/docs/asciidoc/web/webflux.adoc index 3f8cb1bda19..1313a5b9a5b 100644 --- a/src/docs/asciidoc/web/webflux.adoc +++ b/src/docs/asciidoc/web/webflux.adoc @@ -2914,6 +2914,10 @@ of resolvers and transformers (e.g. resources on Amazon S3). When serving only l resources the workaround is to use `ResourceUrlProvider` directly (e.g. through a custom tag) and block for 0 seconds. +Note that when using both `EncodedResourceResolver` (e.g. for serving gzipped or brotli +encoded resources) and `VersionedResourceResolver`, they must be registered in this order. +That ensures content based versions are always computed reliably based on the unencoded file. + http://www.webjars.org/documentation[WebJars] is also supported via `WebJarsResourceResolver` and automatically registered when `"org.webjars:webjars-locator"` is present on the classpath. The resolver can re-write URLs to include the version of the jar and can also diff --git a/src/docs/asciidoc/web/webmvc.adoc b/src/docs/asciidoc/web/webmvc.adoc index 66eba8dec65..c1c79f4504e 100644 --- a/src/docs/asciidoc/web/webmvc.adoc +++ b/src/docs/asciidoc/web/webmvc.adoc @@ -4549,12 +4549,16 @@ In XML, the same: ---- -You can use `ResourceUrlProvider` to rewrite URLs and apply the full chain of resolvers and +You can then use `ResourceUrlProvider` to rewrite URLs and apply the full chain of resolvers and transformers -- e.g. to insert versions. The MVC config provides a `ResourceUrlProvider` bean so it can be injected into others. You can also make the rewrite transparent with the `ResourceUrlEncodingFilter` for Thymeleaf, JSPs, FreeMarker, and others with URL tags that rely on `HttpServletResponse#encodeURL`. +Note that when using both `EncodedResourceResolver` (e.g. for serving gzipped or brotli +encoded resources) and `VersionedResourceResolver`, they must be registered in this order. +That ensures content based versions are always computed reliably based on the unencoded file. + http://www.webjars.org/documentation[WebJars] is also supported via `WebJarsResourceResolver` and automatically registered when `"org.webjars:webjars-locator"` is present on the classpath. The resolver can re-write URLs to include the version of the jar and can also