Skip to content

Commit

Permalink
Introduce support for webjars-locator-lite
Browse files Browse the repository at this point in the history
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
  • Loading branch information
sdeleuze committed Apr 2, 2024
1 parent 81bc586 commit 67edcde
Show file tree
Hide file tree
Showing 16 changed files with 565 additions and 15 deletions.
1 change: 1 addition & 0 deletions framework-platform/framework-platform.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ dependencies {
api("org.testng:testng:7.9.0")
api("org.webjars:underscorejs:1.8.3")
api("org.webjars:webjars-locator-core:0.55")
api("org.webjars:webjars-locator-lite:0.0.2")
api("org.xmlunit:xmlunit-assertj:2.9.1")
api("org.xmlunit:xmlunit-matchers:2.9.1")
api("org.yaml:snakeyaml:2.2")
Expand Down
1 change: 1 addition & 0 deletions spring-webflux/spring-webflux.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies {
optional("org.jetbrains.kotlin:kotlin-stdlib")
optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
optional("org.webjars:webjars-locator-core")
optional("org.webjars:webjars-locator-lite")
testImplementation(testFixtures(project(":spring-beans")))
testImplementation(testFixtures(project(":spring-core")))
testImplementation(testFixtures(project(":spring-web")))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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;
Expand All @@ -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);

Expand Down Expand Up @@ -79,6 +83,7 @@ public ResourceChainRegistration(boolean cacheResources, @Nullable Cache cache)
* @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);
Expand All @@ -88,7 +93,7 @@ public ResourceChainRegistration addResolver(ResourceResolver resolver) {
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;
Expand All @@ -108,10 +113,14 @@ public ResourceChainRegistration addTransformer(ResourceTransformer transformer)
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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@
* @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/";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
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;
Expand Down Expand Up @@ -142,7 +143,7 @@ void resourceChain() {
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),
Expand All @@ -156,7 +157,7 @@ void resourceChainWithoutCaching() {

ResourceWebHandler handler = getHandler("/resources/**");
assertThat(handler.getResourceResolvers()).hasExactlyElementsOfTypes(
WebJarsResourceResolver.class, PathResourceResolver.class);
LiteWebJarsResourceResolver.class, PathResourceResolver.class);
assertThat(handler.getResourceTransformers()).isEmpty();
}

Expand All @@ -172,7 +173,7 @@ void resourceChainWithVersionResolver() {
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}
1 change: 1 addition & 0 deletions spring-webmvc/spring-webmvc.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies {
optional("org.jetbrains.kotlinx:kotlinx-serialization-protobuf")
optional("org.reactivestreams:reactive-streams")
optional("org.webjars:webjars-locator-core")
optional("org.webjars:webjars-locator-lite")
testImplementation(testFixtures(project(":spring-beans")))
testImplementation(testFixtures(project(":spring-context")))
testImplementation(testFixtures(project(":spring-core")))
Expand Down
Loading

0 comments on commit 67edcde

Please sign in to comment.