Introduce support for webjars-locator-lite

This commit introduces support for org.webjars:webjars-locator-lite
via a new LiteWebJarsResourceResolver in Spring MVC and WebFlux, and
deprecates WebJarsResourceResolver which is performing a classpath
scanning that slows down application startup.

Closes gh-27619
This commit is contained in:
Sébastien Deleuze
2024-03-21 16:52:50 +01:00
parent 81bc586e15
commit 67edcde0a2
16 changed files with 565 additions and 15 deletions

View File

@@ -27,6 +27,7 @@ import org.springframework.util.ClassUtils;
import org.springframework.web.reactive.resource.CachingResourceResolver;
import org.springframework.web.reactive.resource.CachingResourceTransformer;
import org.springframework.web.reactive.resource.CssLinkResourceTransformer;
import org.springframework.web.reactive.resource.LiteWebJarsResourceResolver;
import org.springframework.web.reactive.resource.PathResourceResolver;
import org.springframework.web.reactive.resource.ResourceResolver;
import org.springframework.web.reactive.resource.ResourceTransformer;
@@ -43,9 +44,12 @@ public class ResourceChainRegistration {
private static final String DEFAULT_CACHE_NAME = "spring-resource-chain-cache";
private static final boolean isWebJarsAssetLocatorPresent = ClassUtils.isPresent(
private static final boolean isWebJarAssetLocatorPresent = ClassUtils.isPresent(
"org.webjars.WebJarAssetLocator", ResourceChainRegistration.class.getClassLoader());
private static final boolean isWebJarVersionLocatorPresent = ClassUtils.isPresent(
"org.webjars.WebJarVersionLocator", ResourceChainRegistration.class.getClassLoader());
private final List<ResourceResolver> resolvers = new ArrayList<>(4);
@@ -79,6 +83,7 @@ public class ResourceChainRegistration {
* @param resolver the resolver to add
* @return the current instance for chained method invocation
*/
@SuppressWarnings("removal")
public ResourceChainRegistration addResolver(ResourceResolver resolver) {
Assert.notNull(resolver, "The provided ResourceResolver should not be null");
this.resolvers.add(resolver);
@@ -88,7 +93,7 @@ public class ResourceChainRegistration {
else if (resolver instanceof PathResourceResolver) {
this.hasPathResolver = true;
}
else if (resolver instanceof WebJarsResourceResolver) {
else if (resolver instanceof WebJarsResourceResolver || resolver instanceof LiteWebJarsResourceResolver) {
this.hasWebjarsResolver = true;
}
return this;
@@ -108,10 +113,14 @@ public class ResourceChainRegistration {
return this;
}
@SuppressWarnings("removal")
protected List<ResourceResolver> getResourceResolvers() {
if (!this.hasPathResolver) {
List<ResourceResolver> result = new ArrayList<>(this.resolvers);
if (isWebJarsAssetLocatorPresent && !this.hasWebjarsResolver) {
if (isWebJarVersionLocatorPresent && !this.hasWebjarsResolver) {
result.add(new LiteWebJarsResourceResolver());
}
else if (isWebJarAssetLocatorPresent && !this.hasWebjarsResolver) {
result.add(new WebJarsResourceResolver());
}
result.add(new PathResourceResolver());

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2002-2022 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
*
* https://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.util.List;
import org.webjars.WebJarVersionLocator;
import reactor.core.publisher.Mono;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.web.server.ServerWebExchange;
/**
* A {@code ResourceResolver} that delegates to the chain to locate a resource and then
* attempts to find a matching versioned resource contained in a WebJar JAR file.
*
* <p>This allows WebJars.org users to write version agnostic paths in their templates,
* like {@code <script src="/webjars/jquery/jquery.min.js"/>}.
* This path will be resolved to the unique version {@code <script src="/webjars/jquery/1.2.0/jquery.min.js"/>},
* which is a better fit for HTTP caching and version management in applications.
*
* <p>This also resolves resources for version agnostic HTTP requests {@code "GET /jquery/jquery.min.js"}.
*
* <p>This resolver requires the {@code org.webjars:webjars-locator-lite} library
* on the classpath and is automatically registered if that library is present.
*
* @author Sebastien Deleuze
* @since 6.2
* @see <a href="https://www.webjars.org">webjars.org</a>
*/
public class LiteWebJarsResourceResolver extends AbstractResourceResolver {
private static final int WEBJARS_LOCATION_LENGTH = WebJarVersionLocator.WEBJARS_PATH_PREFIX.length() + 1;
private final WebJarVersionLocator webJarAssetLocator;
/**
* Create a {@code LiteWebJarsResourceResolver} with a default {@code WebJarVersionLocator} instance.
*/
public LiteWebJarsResourceResolver() {
this.webJarAssetLocator = new WebJarVersionLocator();
}
/**
* Create a {@code LiteWebJarsResourceResolver} with a custom {@code WebJarVersionLocator} instance,
* e.g. with a custom cache implementation.
*/
public LiteWebJarsResourceResolver(WebJarVersionLocator webJarAssetLocator) {
this.webJarAssetLocator = webJarAssetLocator;
}
@Override
protected Mono<Resource> resolveResourceInternal(@Nullable ServerWebExchange exchange,
String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) {
return chain.resolveResource(exchange, requestPath, locations)
.switchIfEmpty(Mono.defer(() -> {
String webJarsResourcePath = findWebJarResourcePath(requestPath);
if (webJarsResourcePath != null) {
return chain.resolveResource(exchange, webJarsResourcePath, locations);
}
else {
return Mono.empty();
}
}));
}
@Override
protected Mono<String> resolveUrlPathInternal(String resourceUrlPath,
List<? extends Resource> locations, ResourceResolverChain chain) {
return chain.resolveUrlPath(resourceUrlPath, locations)
.switchIfEmpty(Mono.defer(() -> {
String webJarResourcePath = findWebJarResourcePath(resourceUrlPath);
if (webJarResourcePath != null) {
return chain.resolveUrlPath(webJarResourcePath, locations);
}
else {
return Mono.empty();
}
}));
}
@Nullable
protected String findWebJarResourcePath(String path) {
int startOffset = (path.startsWith("/") ? 1 : 0);
int endOffset = path.indexOf('/', 1);
if (endOffset != -1) {
String webjar = path.substring(startOffset, endOffset);
String partialPath = path.substring(endOffset + 1);
String webJarPath = this.webJarAssetLocator.fullPath(webjar, partialPath);
if (webJarPath != null) {
return webJarPath.substring(WEBJARS_LOCATION_LENGTH);
}
}
return null;
}
}

View File

@@ -46,7 +46,9 @@ import org.springframework.web.server.ServerWebExchange;
* @author Brian Clozel
* @since 5.0
* @see <a href="https://www.webjars.org">webjars.org</a>
* @deprecated as of Spring Framework 6.2 in favor of {@link LiteWebJarsResourceResolver}
*/
@Deprecated(forRemoval = true)
public class WebJarsResourceResolver extends AbstractResourceResolver {
private static final String WEBJARS_LOCATION = "META-INF/resources/webjars/";

View File

@@ -36,6 +36,7 @@ import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.resource.CachingResourceResolver;
import org.springframework.web.reactive.resource.CachingResourceTransformer;
import org.springframework.web.reactive.resource.CssLinkResourceTransformer;
import org.springframework.web.reactive.resource.LiteWebJarsResourceResolver;
import org.springframework.web.reactive.resource.PathResourceResolver;
import org.springframework.web.reactive.resource.ResourceResolver;
import org.springframework.web.reactive.resource.ResourceTransformer;
@@ -142,7 +143,7 @@ class ResourceHandlerRegistryTests {
zero -> assertThat(zero).isInstanceOfSatisfying(CachingResourceResolver.class,
cachingResolver -> assertThat(cachingResolver.getCache()).isInstanceOf(ConcurrentMapCache.class)),
one -> assertThat(one).isEqualTo(mockResolver),
two -> assertThat(two).isInstanceOf(WebJarsResourceResolver.class),
two -> assertThat(two).isInstanceOf(LiteWebJarsResourceResolver.class),
three -> assertThat(three).isInstanceOf(PathResourceResolver.class));
assertThat(handler.getResourceTransformers()).satisfiesExactly(
zero -> assertThat(zero).isInstanceOf(CachingResourceTransformer.class),
@@ -156,7 +157,7 @@ class ResourceHandlerRegistryTests {
ResourceWebHandler handler = getHandler("/resources/**");
assertThat(handler.getResourceResolvers()).hasExactlyElementsOfTypes(
WebJarsResourceResolver.class, PathResourceResolver.class);
LiteWebJarsResourceResolver.class, PathResourceResolver.class);
assertThat(handler.getResourceTransformers()).isEmpty();
}
@@ -172,7 +173,7 @@ class ResourceHandlerRegistryTests {
assertThat(handler.getResourceResolvers()).satisfiesExactly(
zero -> assertThat(zero).isInstanceOf(CachingResourceResolver.class),
one -> assertThat(one).isSameAs(versionResolver),
two -> assertThat(two).isInstanceOf(WebJarsResourceResolver.class),
two -> assertThat(two).isInstanceOf(LiteWebJarsResourceResolver.class),
three -> assertThat(three).isInstanceOf(PathResourceResolver.class));
assertThat(handler.getResourceTransformers()).hasExactlyElementsOfTypes(
CachingResourceTransformer.class, CssLinkResourceTransformer.class);

View File

@@ -0,0 +1,153 @@
/*
* Copyright 2002-2024 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
*
* https://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.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.server.MockServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link WebJarsResourceResolver}.
*
* @author Sebastien Deleuze
*/
class LiteWebJarsResourceResolverTests {
private static final Duration TIMEOUT = Duration.ofSeconds(1);
private List<Resource> locations = List.of(new ClassPathResource("/META-INF/resources/webjars"));
// for this to work, an actual WebJar must be on the test classpath
private LiteWebJarsResourceResolver resolver = new LiteWebJarsResourceResolver();
private ResourceResolverChain chain = mock();
private ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(""));
@Test
void resolveUrlExisting() {
String file = "/foo/2.3/foo.txt";
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.just(file));
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT);
assertThat(actual).isEqualTo(file);
verify(this.chain, times(1)).resolveUrlPath(file, this.locations);
}
@Test
void resolveUrlExistingNotInJarFile() {
String file = "foo/foo.txt";
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.empty());
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT);
assertThat(actual).isNull();
verify(this.chain, times(1)).resolveUrlPath(file, this.locations);
verify(this.chain, never()).resolveUrlPath("foo/2.3/foo.txt", this.locations);
}
@Test
void resolveUrlWebJarResource() {
String file = "underscorejs/underscore.js";
String expected = "underscorejs/1.8.3/underscore.js";
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.empty());
given(this.chain.resolveUrlPath(expected, this.locations)).willReturn(Mono.just(expected));
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT);
assertThat(actual).isEqualTo(expected);
verify(this.chain, times(1)).resolveUrlPath(file, this.locations);
verify(this.chain, times(1)).resolveUrlPath(expected, this.locations);
}
@Test
void resolveUrlWebJarResourceNotFound() {
String file = "something/something.js";
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.empty());
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT);
assertThat(actual).isNull();
verify(this.chain, times(1)).resolveUrlPath(file, this.locations);
verify(this.chain, never()).resolveUrlPath(null, this.locations);
}
@Test
void resolveResourceExisting() {
Resource expected = mock();
String file = "foo/2.3/foo.txt";
given(this.chain.resolveResource(this.exchange, file, this.locations)).willReturn(Mono.just(expected));
Resource actual = this.resolver
.resolveResource(this.exchange, file, this.locations, this.chain)
.block(TIMEOUT);
assertThat(actual).isEqualTo(expected);
verify(this.chain, times(1)).resolveResource(this.exchange, file, this.locations);
}
@Test
void resolveResourceNotFound() {
String file = "something/something.js";
given(this.chain.resolveResource(this.exchange, file, this.locations)).willReturn(Mono.empty());
Resource actual = this.resolver
.resolveResource(this.exchange, file, this.locations, this.chain)
.block(TIMEOUT);
assertThat(actual).isNull();
verify(this.chain, times(1)).resolveResource(this.exchange, file, this.locations);
verify(this.chain, never()).resolveResource(this.exchange, null, this.locations);
}
@Test
void resolveResourceWebJar() {
String file = "underscorejs/underscore.js";
given(this.chain.resolveResource(this.exchange, file, this.locations)).willReturn(Mono.empty());
Resource expected = mock();
String expectedPath = "underscorejs/1.8.3/underscore.js";
given(this.chain.resolveResource(this.exchange, expectedPath, this.locations))
.willReturn(Mono.just(expected));
Resource actual = this.resolver
.resolveResource(this.exchange, file, this.locations, this.chain)
.block(TIMEOUT);
assertThat(actual).isEqualTo(expected);
verify(this.chain, times(1)).resolveResource(this.exchange, file, this.locations);
}
}