diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/Resource.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/Resource.java new file mode 100644 index 00000000000..dae49e5afd3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/Resource.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-2025 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.boot.testsupport.classpath.resources; + +import java.nio.file.Path; + +/** + * A resource that is to be made available in tests. + * + * @param path the path of the resoure + * @param additional whether the resource should be made available in addition to those + * that already exist elsewhere + * @author Andy Wilkinson + */ +record Resource(Path path, boolean additional) { + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/Resources.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/Resources.java index 331b2b64059..9d400b61e78 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/Resources.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/Resources.java @@ -26,7 +26,9 @@ import java.nio.file.Paths; import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; import org.springframework.util.Assert; @@ -41,6 +43,8 @@ class Resources { private final Path root; + private final Map resources = new HashMap<>(); + Resources(Path root) { this.root = root; } @@ -60,6 +64,7 @@ class Resources { Files.createDirectories(targetDirectory); } Files.copy(resource, target); + register(resourceName, target, true); unmatchedNames.remove(resourceName); } } @@ -76,7 +81,7 @@ class Resources { return this; } - Resources addResource(String name, String content) { + Resources addResource(String name, String content, boolean additional) { Path resourcePath = this.root.resolve(name); if (Files.isDirectory(resourcePath)) { throw new IllegalStateException( @@ -88,6 +93,7 @@ class Resources { Files.createDirectories(parent); } Files.writeString(resourcePath, processContent(content)); + register(name, resourcePath, additional); } catch (IOException ex) { throw new UncheckedIOException(ex); @@ -95,6 +101,29 @@ class Resources { return this; } + private void register(String name, Path resourcePath, boolean additional) { + Resource resource = new Resource(resourcePath, additional); + register(name, resource); + Path ancestor = resourcePath.getParent(); + while (!this.root.equals(ancestor)) { + Resource ancestorResource = new Resource(ancestor, additional); + register(this.root.relativize(ancestor).toString(), ancestorResource); + ancestor = ancestor.getParent(); + } + } + + private void register(String name, Resource resource) { + this.resources.put(name, resource); + if (Files.isDirectory(resource.path())) { + if (name.endsWith("/")) { + this.resources.put(name.substring(0, name.length() - 1), resource); + } + else { + this.resources.put(name + "/", resource); + } + } + } + private String processContent(String content) { return content.replace("${resourceRoot}", this.root.toString()); } @@ -107,6 +136,7 @@ class Resources { } try { Files.createDirectories(directoryPath); + register(name, directoryPath, true); } catch (IOException ex) { throw new UncheckedIOException(ex); @@ -117,6 +147,7 @@ class Resources { void delete() { try { FileSystemUtils.deleteRecursively(this.root); + this.resources.clear(); } catch (IOException ex) { throw new UncheckedIOException(ex); @@ -127,4 +158,8 @@ class Resources { return this.root; } + Resource find(String name) { + return this.resources.get(name); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/ResourcesClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/ResourcesClassLoader.java index 1459f60680a..2eeef880c1c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/ResourcesClassLoader.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/ResourcesClassLoader.java @@ -19,11 +19,9 @@ package org.springframework.boot.testsupport.classpath.resources; import java.io.IOException; import java.io.UncheckedIOException; import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; -import java.util.List; /** * A {@link ClassLoader} that provides access to {@link Resources resources}. @@ -40,23 +38,32 @@ class ResourcesClassLoader extends ClassLoader { } @Override - protected URL findResource(String name) { - Path resource = this.resources.getRoot().resolve(name); - if (Files.exists(resource)) { - try { - return resource.toUri().toURL(); - } - catch (IOException ex) { - throw new UncheckedIOException(ex); - } + public URL getResource(String name) { + Resource resource = this.resources.find(name); + return (resource != null) ? urlOf(resource) : getParent().getResource(name); + } + + private URL urlOf(Resource resource) { + try { + return resource.path().toUri().toURL(); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); } - return null; } @Override - protected Enumeration findResources(String name) throws IOException { - URL resourceUrl = findResource(name); - return (resourceUrl != null) ? Collections.enumeration(List.of(resourceUrl)) : Collections.emptyEnumeration(); + public Enumeration getResources(String name) throws IOException { + Resource resource = this.resources.find(name); + ArrayList urls = new ArrayList<>(); + if (resource != null) { + URL resourceUrl = urlOf(resource); + urls.add(resourceUrl); + } + if (resource == null || resource.additional()) { + urls.addAll(Collections.list(getParent().getResources(name))); + } + return Collections.enumeration(urls); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/ResourcesExtension.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/ResourcesExtension.java index 72b75d24e36..4d0d9ab2c98 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/ResourcesExtension.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/ResourcesExtension.java @@ -63,7 +63,8 @@ class ResourcesExtension implements BeforeEachCallback, AfterEachCallback, Param Resources resources = new Resources(Files.createTempDirectory("resources")); store.put(RESOURCES_KEY, resources); Method testMethod = context.getRequiredTestMethod(); - resourcesOf(testMethod).forEach((resource) -> resources.addResource(resource.name(), resource.content())); + resourcesOf(testMethod) + .forEach((resource) -> resources.addResource(resource.name(), resource.content(), resource.additional())); resourceDirectoriesOf(testMethod).forEach((directory) -> resources.addDirectory(directory.value())); packageResourcesOf(testMethod).forEach((withPackageResources) -> resources .addPackage(testMethod.getDeclaringClass().getPackage(), withPackageResources.value())); @@ -148,8 +149,7 @@ class ResourcesExtension implements BeforeEachCallback, AfterEachCallback, Param private Object resolveResourcePath(ParameterContext parameterContext, ExtensionContext extensionContext) { Resources resources = getResources(extensionContext); Class parameterType = parameterContext.getParameter().getType(); - Path resourcePath = resources.getRoot() - .resolve(parameterContext.findAnnotation(ResourcePath.class).get().value()); + Path resourcePath = resources.find(parameterContext.findAnnotation(ResourcePath.class).get().value()).path(); if (parameterType.isAssignableFrom(Path.class)) { return resourcePath; } @@ -166,8 +166,7 @@ class ResourcesExtension implements BeforeEachCallback, AfterEachCallback, Param private Object resolveResourceContent(ParameterContext parameterContext, ExtensionContext extensionContext) { Resources resources = getResources(extensionContext); Class parameterType = parameterContext.getParameter().getType(); - Path resourcePath = resources.getRoot() - .resolve(parameterContext.findAnnotation(ResourceContent.class).get().value()); + Path resourcePath = resources.find(parameterContext.findAnnotation(ResourceContent.class).get().value()).path(); if (parameterType.isAssignableFrom(String.class)) { try (InputStream in = Files.newInputStream(resourcePath)) { return StreamUtils.copyToString(in, StandardCharsets.UTF_8); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/WithResource.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/WithResource.java index bb573bb666c..6c19f9f766c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/WithResource.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/classpath/resources/WithResource.java @@ -54,4 +54,11 @@ public @interface WithResource { */ String content() default ""; + /** + * Whether the resource should be available in addition to those that are already on + * the classpath are instead of any existing resources with the same name. + * @return whether this is an additional resource + */ + boolean additional() default true; + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/resources/ResourcesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/resources/ResourcesTests.java index c91d508c68e..be3a319f104 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/resources/ResourcesTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/resources/ResourcesTests.java @@ -18,6 +18,7 @@ package org.springframework.boot.testsupport.classpath.resources; import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -34,99 +35,122 @@ class ResourcesTests { @TempDir private Path root; + private Resources resources; + + @BeforeEach + void setUp() { + this.resources = new Resources(this.root); + } + @Test - void whenAddResourceThenResourceIsCreated() { - new Resources(this.root).addResource("test", "test-content"); + void whenAddResourceThenResourceIsCreatedAndCanBeFound() { + this.resources.addResource("test", "test-content", true); assertThat(this.root.resolve("test")).hasContent("test-content"); + assertThat(this.resources.find("test")).isNotNull(); } @Test void whenAddResourceHasContentReferencingResourceRootThenResourceIsCreatedWithReferenceToRoot() { - new Resources(this.root).addResource("test", "*** ${resourceRoot} ***"); + this.resources.addResource("test", "*** ${resourceRoot} ***", true); assertThat(this.root.resolve("test")).hasContent("*** " + this.root + " ***"); } @Test - void whenAddResourceWithPathThenResourceIsCreated() { - new Resources(this.root).addResource("a/b/c/test", "test-content"); + void whenAddResourceWithPathThenResourceIsCreatedAndItAndItsAncestorsCanBeFound() { + this.resources.addResource("a/b/c/test", "test-content", true); assertThat(this.root.resolve("a/b/c/test")).hasContent("test-content"); + assertThat(this.resources.find("a/b/c/test")).isNotNull(); + assertThat(this.resources.find("a/b/c/")).isNotNull(); + assertThat(this.resources.find("a/b/")).isNotNull(); + assertThat(this.resources.find("a/")).isNotNull(); } @Test void whenAddResourceAndResourceAlreadyExistsThenResourcesIsOverwritten() { - Resources resources = new Resources(this.root); - resources.addResource("a/b/c/test", "original-content"); - resources.addResource("a/b/c/test", "new-content"); + this.resources.addResource("a/b/c/test", "original-content", true); + this.resources.addResource("a/b/c/test", "new-content", true); assertThat(this.root.resolve("a/b/c/test")).hasContent("new-content"); } @Test - void whenAddPackageThenNamedResourcesFromPackageAreCreated() { - new Resources(this.root).addPackage(getClass().getPackage(), - new String[] { "resource-1.txt", "sub/resource-3.txt" }); + void whenAddPackageThenNamedResourcesFromPackageAreCreatedAndCanBeFound() { + this.resources.addPackage(getClass().getPackage(), new String[] { "resource-1.txt", "sub/resource-3.txt" }); assertThat(this.root.resolve("resource-1.txt")).hasContent("one"); assertThat(this.root.resolve("resource-2.txt")).doesNotExist(); assertThat(this.root.resolve("sub/resource-3.txt")).hasContent("three"); + assertThat(this.resources.find("resource-1.txt")).isNotNull(); + assertThat(this.resources.find("resource-2.txt")).isNull(); + assertThat(this.resources.find("sub/resource-3.txt")).isNotNull(); + assertThat(this.resources.find("sub/")).isNotNull(); } @Test - void whenAddResourceAndDeleteThenResourceDoesNotExist() { - Resources resources = new Resources(this.root); - resources.addResource("test", "test-content"); + void whenAddResourceAndDeleteThenResourceDoesNotExistAndCannotBeFound() { + this.resources.addResource("test", "test-content", true); assertThat(this.root.resolve("test")).hasContent("test-content"); - resources.delete(); + assertThat(this.resources.find("test")).isNotNull(); + this.resources.delete(); assertThat(this.root.resolve("test")).doesNotExist(); + assertThat(this.resources.find("test")).isNull(); } @Test - void whenAddPackageAndDeleteThenResourcesDoNotExist() { - Resources resources = new Resources(this.root); - resources.addPackage(getClass().getPackage(), + void whenAddPackageAndDeleteThenResourcesDoNotExistAndCannotBeFound() { + this.resources.addPackage(getClass().getPackage(), new String[] { "resource-1.txt", "resource-2.txt", "sub/resource-3.txt" }); assertThat(this.root.resolve("resource-1.txt")).hasContent("one"); assertThat(this.root.resolve("resource-2.txt")).hasContent("two"); assertThat(this.root.resolve("sub/resource-3.txt")).hasContent("three"); - resources.delete(); + assertThat(this.resources.find("resource-1.txt")).isNotNull(); + assertThat(this.resources.find("resource-2.txt")).isNotNull(); + assertThat(this.resources.find("sub/resource-3.txt")).isNotNull(); + assertThat(this.resources.find("sub/")).isNotNull(); + this.resources.delete(); assertThat(this.root.resolve("resource-1.txt")).doesNotExist(); assertThat(this.root.resolve("resource-2.txt")).doesNotExist(); assertThat(this.root.resolve("sub/resource-3.txt")).doesNotExist(); assertThat(this.root.resolve("sub")).doesNotExist(); + assertThat(this.resources.find("resource-1.txt")).isNull(); + assertThat(this.resources.find("resource-2.txt")).isNull(); + assertThat(this.resources.find("sub/resource-3.txt")).isNull(); + assertThat(this.resources.find("sub/")).isNull(); } @Test - void whenAddDirectoryThenDirectoryIsCreated() { - Resources resources = new Resources(this.root); - resources.addDirectory("dir"); + void whenAddDirectoryThenDirectoryIsCreatedAndCanBeFound() { + this.resources.addDirectory("dir"); assertThat(this.root.resolve("dir")).isDirectory(); + assertThat(this.resources.find("dir/")).isNotNull(); } @Test - void whenAddDirectoryWithPathThenDirectoryIsCreated() { - Resources resources = new Resources(this.root); - resources.addDirectory("one/two/three/dir"); + void whenAddDirectoryWithPathThenDirectoryIsCreatedAndItAndItsAncestorsCanBeFound() { + this.resources.addDirectory("one/two/three/dir"); assertThat(this.root.resolve("one/two/three/dir")).isDirectory(); + assertThat(this.resources.find("one/two/three/dir/")).isNotNull(); + assertThat(this.resources.find("one/two/three/")).isNotNull(); + assertThat(this.resources.find("one/two/")).isNotNull(); + assertThat(this.resources.find("one/")).isNotNull(); } @Test void whenAddDirectoryAndDirectoryAlreadyExistsThenDoesNotThrow() { - Resources resources = new Resources(this.root); - resources.addDirectory("one/two/three/dir"); - resources.addDirectory("one/two/three/dir"); + this.resources.addDirectory("one/two/three/dir"); + this.resources.addDirectory("one/two/three/dir"); assertThat(this.root.resolve("one/two/three/dir")).isDirectory(); } @Test void whenAddDirectoryAndResourceAlreadyExistsThenIllegalStateExceptionIsThrown() { - Resources resources = new Resources(this.root); - resources.addResource("one/two/three/", "content"); - assertThatIllegalStateException().isThrownBy(() -> resources.addDirectory("one/two/three")); + this.resources.addResource("one/two/three/", "content", true); + assertThatIllegalStateException().isThrownBy(() -> this.resources.addDirectory("one/two/three")); } @Test void whenAddResourceAndDirectoryAlreadyExistsThenIllegalStateExceptionIsThrown() { - Resources resources = new Resources(this.root); - resources.addDirectory("one/two/three"); - assertThatIllegalStateException().isThrownBy(() -> resources.addResource("one/two/three", "content")); + this.resources.addDirectory("one/two/three"); + assertThatIllegalStateException() + .isThrownBy(() -> this.resources.addResource("one/two/three", "content", true)); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/resources/WithResourceTests.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/resources/WithResourceTests.java index 87e15b16c41..5142196b71a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/resources/WithResourceTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/test/java/org/springframework/boot/testsupport/classpath/resources/WithResourceTests.java @@ -24,6 +24,8 @@ import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import static org.assertj.core.api.Assertions.assertThat; @@ -97,4 +99,26 @@ class WithResourceTests { assertThat(new ClassPathResource("3").getContentAsString(StandardCharsets.UTF_8)).isEqualTo("three"); } + @Test + @WithResource(name = "org/springframework/boot/testsupport/classpath/resources/resource-1.txt", + content = "from-with-resource") + void whenWithResourceCreatesResourceThatIsAvailableElsewhereBothResourcesCanBeLoaded() throws IOException { + Resource[] resources = new PathMatchingResourcePatternResolver() + .getResources("classpath*:org/springframework/boot/testsupport/classpath/resources/resource-1.txt"); + assertThat(resources).hasSize(2); + assertThat(resources).extracting((resource) -> resource.getContentAsString(StandardCharsets.UTF_8)) + .containsExactly("from-with-resource", "one"); + } + + @Test + @WithResource(name = "org/springframework/boot/testsupport/classpath/resources/resource-1.txt", + content = "from-with-resource", additional = false) + void whenWithResourceCreatesResourceThatIsNotAdditionalThenResourceThatIsAvailableElsewhereCannotBeLoaded() + throws IOException { + Resource[] resources = new PathMatchingResourcePatternResolver() + .getResources("classpath*:org/springframework/boot/testsupport/classpath/resources/resource-1.txt"); + assertThat(resources).hasSize(1); + assertThat(resources[0].getContentAsString(StandardCharsets.UTF_8)).isEqualTo("from-with-resource"); + } + }