Skip to content

Commit 67edcde

Browse files
committed
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
1 parent 81bc586 commit 67edcde

File tree

16 files changed

+565
-15
lines changed

16 files changed

+565
-15
lines changed

framework-platform/framework-platform.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ dependencies {
143143
api("org.testng:testng:7.9.0")
144144
api("org.webjars:underscorejs:1.8.3")
145145
api("org.webjars:webjars-locator-core:0.55")
146+
api("org.webjars:webjars-locator-lite:0.0.2")
146147
api("org.xmlunit:xmlunit-assertj:2.9.1")
147148
api("org.xmlunit:xmlunit-matchers:2.9.1")
148149
api("org.yaml:snakeyaml:2.2")

spring-webflux/spring-webflux.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies {
3232
optional("org.jetbrains.kotlin:kotlin-stdlib")
3333
optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
3434
optional("org.webjars:webjars-locator-core")
35+
optional("org.webjars:webjars-locator-lite")
3536
testImplementation(testFixtures(project(":spring-beans")))
3637
testImplementation(testFixtures(project(":spring-core")))
3738
testImplementation(testFixtures(project(":spring-web")))

spring-webflux/src/main/java/org/springframework/web/reactive/config/ResourceChainRegistration.java

+12-3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.springframework.web.reactive.resource.CachingResourceResolver;
2828
import org.springframework.web.reactive.resource.CachingResourceTransformer;
2929
import org.springframework.web.reactive.resource.CssLinkResourceTransformer;
30+
import org.springframework.web.reactive.resource.LiteWebJarsResourceResolver;
3031
import org.springframework.web.reactive.resource.PathResourceResolver;
3132
import org.springframework.web.reactive.resource.ResourceResolver;
3233
import org.springframework.web.reactive.resource.ResourceTransformer;
@@ -43,9 +44,12 @@ public class ResourceChainRegistration {
4344

4445
private static final String DEFAULT_CACHE_NAME = "spring-resource-chain-cache";
4546

46-
private static final boolean isWebJarsAssetLocatorPresent = ClassUtils.isPresent(
47+
private static final boolean isWebJarAssetLocatorPresent = ClassUtils.isPresent(
4748
"org.webjars.WebJarAssetLocator", ResourceChainRegistration.class.getClassLoader());
4849

50+
private static final boolean isWebJarVersionLocatorPresent = ClassUtils.isPresent(
51+
"org.webjars.WebJarVersionLocator", ResourceChainRegistration.class.getClassLoader());
52+
4953

5054
private final List<ResourceResolver> resolvers = new ArrayList<>(4);
5155

@@ -79,6 +83,7 @@ public ResourceChainRegistration(boolean cacheResources, @Nullable Cache cache)
7983
* @param resolver the resolver to add
8084
* @return the current instance for chained method invocation
8185
*/
86+
@SuppressWarnings("removal")
8287
public ResourceChainRegistration addResolver(ResourceResolver resolver) {
8388
Assert.notNull(resolver, "The provided ResourceResolver should not be null");
8489
this.resolvers.add(resolver);
@@ -88,7 +93,7 @@ public ResourceChainRegistration addResolver(ResourceResolver resolver) {
8893
else if (resolver instanceof PathResourceResolver) {
8994
this.hasPathResolver = true;
9095
}
91-
else if (resolver instanceof WebJarsResourceResolver) {
96+
else if (resolver instanceof WebJarsResourceResolver || resolver instanceof LiteWebJarsResourceResolver) {
9297
this.hasWebjarsResolver = true;
9398
}
9499
return this;
@@ -108,10 +113,14 @@ public ResourceChainRegistration addTransformer(ResourceTransformer transformer)
108113
return this;
109114
}
110115

116+
@SuppressWarnings("removal")
111117
protected List<ResourceResolver> getResourceResolvers() {
112118
if (!this.hasPathResolver) {
113119
List<ResourceResolver> result = new ArrayList<>(this.resolvers);
114-
if (isWebJarsAssetLocatorPresent && !this.hasWebjarsResolver) {
120+
if (isWebJarVersionLocatorPresent && !this.hasWebjarsResolver) {
121+
result.add(new LiteWebJarsResourceResolver());
122+
}
123+
else if (isWebJarAssetLocatorPresent && !this.hasWebjarsResolver) {
115124
result.add(new WebJarsResourceResolver());
116125
}
117126
result.add(new PathResourceResolver());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright 2002-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.reactive.resource;
18+
19+
import java.util.List;
20+
21+
import org.webjars.WebJarVersionLocator;
22+
import reactor.core.publisher.Mono;
23+
24+
import org.springframework.core.io.Resource;
25+
import org.springframework.lang.Nullable;
26+
import org.springframework.web.server.ServerWebExchange;
27+
28+
/**
29+
* A {@code ResourceResolver} that delegates to the chain to locate a resource and then
30+
* attempts to find a matching versioned resource contained in a WebJar JAR file.
31+
*
32+
* <p>This allows WebJars.org users to write version agnostic paths in their templates,
33+
* like {@code <script src="/webjars/jquery/jquery.min.js"/>}.
34+
* This path will be resolved to the unique version {@code <script src="/webjars/jquery/1.2.0/jquery.min.js"/>},
35+
* which is a better fit for HTTP caching and version management in applications.
36+
*
37+
* <p>This also resolves resources for version agnostic HTTP requests {@code "GET /jquery/jquery.min.js"}.
38+
*
39+
* <p>This resolver requires the {@code org.webjars:webjars-locator-lite} library
40+
* on the classpath and is automatically registered if that library is present.
41+
*
42+
* @author Sebastien Deleuze
43+
* @since 6.2
44+
* @see <a href="https://www.webjars.org">webjars.org</a>
45+
*/
46+
public class LiteWebJarsResourceResolver extends AbstractResourceResolver {
47+
48+
private static final int WEBJARS_LOCATION_LENGTH = WebJarVersionLocator.WEBJARS_PATH_PREFIX.length() + 1;
49+
50+
private final WebJarVersionLocator webJarAssetLocator;
51+
52+
/**
53+
* Create a {@code LiteWebJarsResourceResolver} with a default {@code WebJarVersionLocator} instance.
54+
*/
55+
public LiteWebJarsResourceResolver() {
56+
this.webJarAssetLocator = new WebJarVersionLocator();
57+
}
58+
59+
/**
60+
* Create a {@code LiteWebJarsResourceResolver} with a custom {@code WebJarVersionLocator} instance,
61+
* e.g. with a custom cache implementation.
62+
*/
63+
public LiteWebJarsResourceResolver(WebJarVersionLocator webJarAssetLocator) {
64+
this.webJarAssetLocator = webJarAssetLocator;
65+
}
66+
67+
@Override
68+
protected Mono<Resource> resolveResourceInternal(@Nullable ServerWebExchange exchange,
69+
String requestPath, List<? extends Resource> locations, ResourceResolverChain chain) {
70+
71+
return chain.resolveResource(exchange, requestPath, locations)
72+
.switchIfEmpty(Mono.defer(() -> {
73+
String webJarsResourcePath = findWebJarResourcePath(requestPath);
74+
if (webJarsResourcePath != null) {
75+
return chain.resolveResource(exchange, webJarsResourcePath, locations);
76+
}
77+
else {
78+
return Mono.empty();
79+
}
80+
}));
81+
}
82+
83+
@Override
84+
protected Mono<String> resolveUrlPathInternal(String resourceUrlPath,
85+
List<? extends Resource> locations, ResourceResolverChain chain) {
86+
87+
return chain.resolveUrlPath(resourceUrlPath, locations)
88+
.switchIfEmpty(Mono.defer(() -> {
89+
String webJarResourcePath = findWebJarResourcePath(resourceUrlPath);
90+
if (webJarResourcePath != null) {
91+
return chain.resolveUrlPath(webJarResourcePath, locations);
92+
}
93+
else {
94+
return Mono.empty();
95+
}
96+
}));
97+
}
98+
99+
@Nullable
100+
protected String findWebJarResourcePath(String path) {
101+
int startOffset = (path.startsWith("/") ? 1 : 0);
102+
int endOffset = path.indexOf('/', 1);
103+
if (endOffset != -1) {
104+
String webjar = path.substring(startOffset, endOffset);
105+
String partialPath = path.substring(endOffset + 1);
106+
String webJarPath = this.webJarAssetLocator.fullPath(webjar, partialPath);
107+
if (webJarPath != null) {
108+
return webJarPath.substring(WEBJARS_LOCATION_LENGTH);
109+
}
110+
}
111+
return null;
112+
}
113+
114+
}

spring-webflux/src/main/java/org/springframework/web/reactive/resource/WebJarsResourceResolver.java

+2
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
* @author Brian Clozel
4747
* @since 5.0
4848
* @see <a href="https://www.webjars.org">webjars.org</a>
49+
* @deprecated as of Spring Framework 6.2 in favor of {@link LiteWebJarsResourceResolver}
4950
*/
51+
@Deprecated(forRemoval = true)
5052
public class WebJarsResourceResolver extends AbstractResourceResolver {
5153

5254
private static final String WEBJARS_LOCATION = "META-INF/resources/webjars/";

spring-webflux/src/test/java/org/springframework/web/reactive/config/ResourceHandlerRegistryTests.java

+4-3
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.springframework.web.reactive.resource.CachingResourceResolver;
3737
import org.springframework.web.reactive.resource.CachingResourceTransformer;
3838
import org.springframework.web.reactive.resource.CssLinkResourceTransformer;
39+
import org.springframework.web.reactive.resource.LiteWebJarsResourceResolver;
3940
import org.springframework.web.reactive.resource.PathResourceResolver;
4041
import org.springframework.web.reactive.resource.ResourceResolver;
4142
import org.springframework.web.reactive.resource.ResourceTransformer;
@@ -142,7 +143,7 @@ void resourceChain() {
142143
zero -> assertThat(zero).isInstanceOfSatisfying(CachingResourceResolver.class,
143144
cachingResolver -> assertThat(cachingResolver.getCache()).isInstanceOf(ConcurrentMapCache.class)),
144145
one -> assertThat(one).isEqualTo(mockResolver),
145-
two -> assertThat(two).isInstanceOf(WebJarsResourceResolver.class),
146+
two -> assertThat(two).isInstanceOf(LiteWebJarsResourceResolver.class),
146147
three -> assertThat(three).isInstanceOf(PathResourceResolver.class));
147148
assertThat(handler.getResourceTransformers()).satisfiesExactly(
148149
zero -> assertThat(zero).isInstanceOf(CachingResourceTransformer.class),
@@ -156,7 +157,7 @@ void resourceChainWithoutCaching() {
156157

157158
ResourceWebHandler handler = getHandler("/resources/**");
158159
assertThat(handler.getResourceResolvers()).hasExactlyElementsOfTypes(
159-
WebJarsResourceResolver.class, PathResourceResolver.class);
160+
LiteWebJarsResourceResolver.class, PathResourceResolver.class);
160161
assertThat(handler.getResourceTransformers()).isEmpty();
161162
}
162163

@@ -172,7 +173,7 @@ void resourceChainWithVersionResolver() {
172173
assertThat(handler.getResourceResolvers()).satisfiesExactly(
173174
zero -> assertThat(zero).isInstanceOf(CachingResourceResolver.class),
174175
one -> assertThat(one).isSameAs(versionResolver),
175-
two -> assertThat(two).isInstanceOf(WebJarsResourceResolver.class),
176+
two -> assertThat(two).isInstanceOf(LiteWebJarsResourceResolver.class),
176177
three -> assertThat(three).isInstanceOf(PathResourceResolver.class));
177178
assertThat(handler.getResourceTransformers()).hasExactlyElementsOfTypes(
178179
CachingResourceTransformer.class, CssLinkResourceTransformer.class);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.reactive.resource;
18+
19+
import java.time.Duration;
20+
import java.util.List;
21+
22+
import org.junit.jupiter.api.Test;
23+
import reactor.core.publisher.Mono;
24+
25+
import org.springframework.core.io.ClassPathResource;
26+
import org.springframework.core.io.Resource;
27+
import org.springframework.web.server.ServerWebExchange;
28+
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
29+
import org.springframework.web.testfixture.server.MockServerWebExchange;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.mockito.BDDMockito.given;
33+
import static org.mockito.Mockito.mock;
34+
import static org.mockito.Mockito.never;
35+
import static org.mockito.Mockito.times;
36+
import static org.mockito.Mockito.verify;
37+
38+
/**
39+
* Tests for {@link WebJarsResourceResolver}.
40+
*
41+
* @author Sebastien Deleuze
42+
*/
43+
class LiteWebJarsResourceResolverTests {
44+
45+
private static final Duration TIMEOUT = Duration.ofSeconds(1);
46+
47+
48+
private List<Resource> locations = List.of(new ClassPathResource("/META-INF/resources/webjars"));
49+
50+
// for this to work, an actual WebJar must be on the test classpath
51+
private LiteWebJarsResourceResolver resolver = new LiteWebJarsResourceResolver();
52+
53+
private ResourceResolverChain chain = mock();
54+
55+
private ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(""));
56+
57+
58+
@Test
59+
void resolveUrlExisting() {
60+
String file = "/foo/2.3/foo.txt";
61+
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.just(file));
62+
63+
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT);
64+
65+
assertThat(actual).isEqualTo(file);
66+
verify(this.chain, times(1)).resolveUrlPath(file, this.locations);
67+
}
68+
69+
@Test
70+
void resolveUrlExistingNotInJarFile() {
71+
String file = "foo/foo.txt";
72+
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.empty());
73+
74+
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT);
75+
76+
assertThat(actual).isNull();
77+
verify(this.chain, times(1)).resolveUrlPath(file, this.locations);
78+
verify(this.chain, never()).resolveUrlPath("foo/2.3/foo.txt", this.locations);
79+
}
80+
81+
@Test
82+
void resolveUrlWebJarResource() {
83+
String file = "underscorejs/underscore.js";
84+
String expected = "underscorejs/1.8.3/underscore.js";
85+
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.empty());
86+
given(this.chain.resolveUrlPath(expected, this.locations)).willReturn(Mono.just(expected));
87+
88+
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT);
89+
90+
assertThat(actual).isEqualTo(expected);
91+
verify(this.chain, times(1)).resolveUrlPath(file, this.locations);
92+
verify(this.chain, times(1)).resolveUrlPath(expected, this.locations);
93+
}
94+
95+
@Test
96+
void resolveUrlWebJarResourceNotFound() {
97+
String file = "something/something.js";
98+
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.empty());
99+
100+
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT);
101+
102+
assertThat(actual).isNull();
103+
verify(this.chain, times(1)).resolveUrlPath(file, this.locations);
104+
verify(this.chain, never()).resolveUrlPath(null, this.locations);
105+
}
106+
107+
@Test
108+
void resolveResourceExisting() {
109+
Resource expected = mock();
110+
String file = "foo/2.3/foo.txt";
111+
given(this.chain.resolveResource(this.exchange, file, this.locations)).willReturn(Mono.just(expected));
112+
113+
Resource actual = this.resolver
114+
.resolveResource(this.exchange, file, this.locations, this.chain)
115+
.block(TIMEOUT);
116+
117+
assertThat(actual).isEqualTo(expected);
118+
verify(this.chain, times(1)).resolveResource(this.exchange, file, this.locations);
119+
}
120+
121+
@Test
122+
void resolveResourceNotFound() {
123+
String file = "something/something.js";
124+
given(this.chain.resolveResource(this.exchange, file, this.locations)).willReturn(Mono.empty());
125+
126+
Resource actual = this.resolver
127+
.resolveResource(this.exchange, file, this.locations, this.chain)
128+
.block(TIMEOUT);
129+
130+
assertThat(actual).isNull();
131+
verify(this.chain, times(1)).resolveResource(this.exchange, file, this.locations);
132+
verify(this.chain, never()).resolveResource(this.exchange, null, this.locations);
133+
}
134+
135+
@Test
136+
void resolveResourceWebJar() {
137+
String file = "underscorejs/underscore.js";
138+
given(this.chain.resolveResource(this.exchange, file, this.locations)).willReturn(Mono.empty());
139+
140+
Resource expected = mock();
141+
String expectedPath = "underscorejs/1.8.3/underscore.js";
142+
given(this.chain.resolveResource(this.exchange, expectedPath, this.locations))
143+
.willReturn(Mono.just(expected));
144+
145+
Resource actual = this.resolver
146+
.resolveResource(this.exchange, file, this.locations, this.chain)
147+
.block(TIMEOUT);
148+
149+
assertThat(actual).isEqualTo(expected);
150+
verify(this.chain, times(1)).resolveResource(this.exchange, file, this.locations);
151+
}
152+
153+
}

spring-webmvc/spring-webmvc.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies {
3939
optional("org.jetbrains.kotlinx:kotlinx-serialization-protobuf")
4040
optional("org.reactivestreams:reactive-streams")
4141
optional("org.webjars:webjars-locator-core")
42+
optional("org.webjars:webjars-locator-lite")
4243
testImplementation(testFixtures(project(":spring-beans")))
4344
testImplementation(testFixtures(project(":spring-context")))
4445
testImplementation(testFixtures(project(":spring-core")))

0 commit comments

Comments
 (0)