Browse Source
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-27619pull/32568/head
16 changed files with 565 additions and 15 deletions
@ -0,0 +1,114 @@
@@ -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; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,153 @@
@@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
|
||||
/* |
||||
* 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.servlet.resource; |
||||
|
||||
import java.util.List; |
||||
|
||||
import jakarta.servlet.http.HttpServletRequest; |
||||
import org.webjars.WebJarVersionLocator; |
||||
|
||||
import org.springframework.core.io.Resource; |
||||
import org.springframework.lang.Nullable; |
||||
|
||||
/** |
||||
* 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 org.springframework.web.servlet.config.annotation.ResourceChainRegistration |
||||
* @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 |
||||
@Nullable |
||||
protected Resource resolveResourceInternal(@Nullable HttpServletRequest request, String requestPath, |
||||
List<? extends Resource> locations, ResourceResolverChain chain) { |
||||
|
||||
Resource resolved = chain.resolveResource(request, requestPath, locations); |
||||
if (resolved == null) { |
||||
String webJarResourcePath = findWebJarResourcePath(requestPath); |
||||
if (webJarResourcePath != null) { |
||||
return chain.resolveResource(request, webJarResourcePath, locations); |
||||
} |
||||
} |
||||
return resolved; |
||||
} |
||||
|
||||
@Override |
||||
@Nullable |
||||
protected String resolveUrlPathInternal(String resourceUrlPath, |
||||
List<? extends Resource> locations, ResourceResolverChain chain) { |
||||
|
||||
String path = chain.resolveUrlPath(resourceUrlPath, locations); |
||||
if (path == null) { |
||||
String webJarResourcePath = findWebJarResourcePath(resourceUrlPath); |
||||
if (webJarResourcePath != null) { |
||||
return chain.resolveUrlPath(webJarResourcePath, locations); |
||||
} |
||||
} |
||||
return path; |
||||
} |
||||
|
||||
@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; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
|
||||
/* |
||||
* 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.servlet.resource; |
||||
|
||||
import java.util.List; |
||||
|
||||
import jakarta.servlet.http.HttpServletRequest; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.core.io.ClassPathResource; |
||||
import org.springframework.core.io.Resource; |
||||
import org.springframework.web.testfixture.servlet.MockHttpServletRequest; |
||||
|
||||
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 LiteWebJarsResourceResolver}. |
||||
* |
||||
* @author Sebastien Deleuze |
||||
*/ |
||||
class LiteWebJarsResourceResolverTests { |
||||
|
||||
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 HttpServletRequest request = new MockHttpServletRequest(); |
||||
|
||||
|
||||
@Test |
||||
void resolveUrlExisting() { |
||||
String file = "/foo/2.3/foo.txt"; |
||||
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(file); |
||||
|
||||
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain); |
||||
|
||||
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(null); |
||||
|
||||
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain); |
||||
|
||||
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(null); |
||||
given(this.chain.resolveUrlPath(expected, this.locations)).willReturn(expected); |
||||
|
||||
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain); |
||||
|
||||
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(null); |
||||
|
||||
String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain); |
||||
|
||||
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.request, file, this.locations)).willReturn(expected); |
||||
|
||||
Resource actual = this.resolver.resolveResource(this.request, file, this.locations, this.chain); |
||||
|
||||
assertThat(actual).isEqualTo(expected); |
||||
verify(this.chain, times(1)).resolveResource(this.request, file, this.locations); |
||||
} |
||||
|
||||
@Test |
||||
void resolveResourceNotFound() { |
||||
String file = "something/something.js"; |
||||
given(this.chain.resolveUrlPath(file, this.locations)).willReturn(null); |
||||
|
||||
Resource actual = this.resolver.resolveResource(this.request, file, this.locations, this.chain); |
||||
|
||||
assertThat(actual).isNull(); |
||||
verify(this.chain, times(1)).resolveResource(this.request, file, this.locations); |
||||
verify(this.chain, never()).resolveResource(this.request, null, this.locations); |
||||
} |
||||
|
||||
@Test |
||||
void resolveResourceWebJar() { |
||||
Resource expected = mock(); |
||||
String file = "underscorejs/underscore.js"; |
||||
String expectedPath = "underscorejs/1.8.3/underscore.js"; |
||||
given(this.chain.resolveResource(this.request, expectedPath, this.locations)).willReturn(expected); |
||||
|
||||
Resource actual = this.resolver.resolveResource(this.request, file, this.locations, this.chain); |
||||
|
||||
assertThat(actual).isEqualTo(expected); |
||||
verify(this.chain, times(1)).resolveResource(this.request, file, this.locations); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue