Skip to content

Commit f364fa9

Browse files
committed
improve tests for scanning packages with custom ClassLoader
One way how we tackle scanning class files from the classpath is asking the context `ClassLoader` for `getResources(..)`. We now add an explicit test for this. We also document by a test that `getResources(..)` doesn't do what we want if the directory entries are missing from a JAR and we use some `ClassLoader` derived from `URLClassLoader`. When creating JARs we can choose if we want to add ZIP entries for the folders as well or skip them and only add entries for the actual class files. But in case we're not adding those directory entries, any `URLClassLoader.getResources(..)` will return an empty result when asked for this directory. This unfortunately makes the behavior quite inconsistent. We have some mitigation in place to also analyze the classpath and scan through the JARs on the classpath with a prefix logic that ignores if the entries for the directory are present. But in case we really only have a customized `ClassLoader` without any directory entries in a JAR there is not much the `ClassLoader` API allows to do. Signed-off-by: Peter Gafert <peter.gafert@tngtech.com>
1 parent 3fc943b commit f364fa9

3 files changed

Lines changed: 132 additions & 5 deletions

File tree

archunit/src/test/java/com/tngtech/archunit/core/importer/LocationTest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,12 @@ private Path absolutePathOf(Class<?> clazz) throws URISyntaxException {
173173
return new File(urlOfClass(clazz).toURI()).getAbsoluteFile().toPath();
174174
}
175175

176-
private URI jarUriOfEntry(JarFile jarFile, String entry) {
176+
static URI jarUriOfEntry(JarFile jarFile, String entry) {
177177
return jarUriOfEntry(jarFile, NormalizedResourceName.from(entry));
178178
}
179179

180-
private URI jarUriOfEntry(JarFile jarFile, NormalizedResourceName entry) {
181-
return URI.create("jar:" + new File(jarFile.getName()).toURI().toString() + "!/" + entry);
180+
private static URI jarUriOfEntry(JarFile jarFile, NormalizedResourceName entry) {
181+
return URI.create("jar:" + new File(jarFile.getName()).toURI() + "!/" + entry);
182182
}
183183

184184
@Test
@@ -342,7 +342,7 @@ private static InputStream streamOfClass(Class<?> clazz) {
342342
return clazz.getResourceAsStream(classFileResource(clazz));
343343
}
344344

345-
private static NormalizedResourceName classFileEntry(Class<?> clazz) {
345+
static NormalizedResourceName classFileEntry(Class<?> clazz) {
346346
return NormalizedResourceName.from(classFileResource(clazz));
347347
}
348348

archunit/src/test/java/com/tngtech/archunit/core/importer/LocationsTest.java

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
package com.tngtech.archunit.core.importer;
22

3+
import java.io.IOException;
34
import java.io.InputStream;
5+
import java.net.MalformedURLException;
46
import java.net.URI;
57
import java.net.URL;
8+
import java.net.URLClassLoader;
9+
import java.util.Arrays;
610
import java.util.Collection;
711
import java.util.Set;
12+
import java.util.jar.JarFile;
13+
import java.util.stream.Stream;
14+
import java.util.stream.StreamSupport;
815

916
import com.google.common.collect.ImmutableList;
17+
import com.tngtech.archunit.core.importer.testexamples.SomeEnum;
1018
import com.tngtech.java.junit.dataprovider.DataProvider;
1119
import org.junit.Rule;
1220
import org.junit.Test;
1321

1422
import static com.google.common.collect.Iterables.getOnlyElement;
23+
import static com.google.common.io.ByteStreams.toByteArray;
24+
import static com.tngtech.archunit.core.importer.LocationTest.classFileEntry;
25+
import static com.tngtech.archunit.core.importer.LocationTest.jarUriOfEntry;
1526
import static com.tngtech.archunit.core.importer.LocationTest.urlOfClass;
1627
import static java.util.stream.Collectors.toSet;
1728
import static org.assertj.core.api.Assertions.assertThat;
@@ -54,6 +65,7 @@ public void locations_of_packages_within_JAR_URIs() throws Exception {
5465
* Jar file didn't have an entry for the respective folder (e.g. java.io vs /java/io).
5566
*/
5667
@Test
68+
@SuppressWarnings("EmptyTryBlock")
5769
public void locations_of_packages_within_JAR_URIs_that_do_not_contain_package_folder() throws Exception {
5870
independentClasspathRule.configureClasspath();
5971

@@ -71,6 +83,55 @@ public void locations_of_packages_within_JAR_URIs_that_do_not_contain_package_fo
7183
.hasSize(independentClasspathRule.getNamesOfClasses().size());
7284
}
7385

86+
@Test
87+
@SuppressWarnings("OptionalGetWithoutIsPresent")
88+
public void locations_of_packages_from_custom_ClassLoader_for_JARs_with_directory_entries() throws IOException {
89+
JarFile jarFile = new TestJarFile()
90+
.withDirectoryEntries()
91+
.withEntry(classFileEntry(SomeEnum.class).toAbsolutePath())
92+
.create();
93+
URL jarUrl = getJarUrlOf(jarFile);
94+
95+
Thread.currentThread().setContextClassLoader(new URLClassLoader(new URL[]{jarUrl}, null));
96+
97+
Location location = Locations.ofPackage(SomeEnum.class.getPackage().getName()).stream()
98+
.filter(it -> it.contains(jarUrl.toString()))
99+
.findFirst()
100+
.get();
101+
102+
byte[] expectedClassContent = toByteArray(urlOfClass(SomeEnum.class).openStream());
103+
Stream<byte[]> actualClassContents = stream(location.asClassFileSource(new ImportOptions()))
104+
.map(it -> unchecked(() -> toByteArray(it.openStream())));
105+
106+
boolean containsExpectedContent = actualClassContents.anyMatch(it -> Arrays.equals(it, expectedClassContent));
107+
assertThat(containsExpectedContent)
108+
.as("one of the actual class files has the expected class file content")
109+
.isTrue();
110+
}
111+
112+
/**
113+
* This is a known limitation for now: If the JAR file doesn't contain directory entries, then asking
114+
* the {@link ClassLoader} for all resources within a directory (which happens when we look for a package)
115+
* will not return anything.
116+
* For this we have some mitigations to additionally search the classpath, but in case this really is
117+
* a highly customized {@link ClassLoader} that doesn't expose any URLs there is not much more we can do.
118+
*/
119+
@Test
120+
public void locations_of_packages_from_custom_ClassLoader_for_JARs_without_directory_entries() throws IOException {
121+
JarFile jarFile = new TestJarFile()
122+
.withoutDirectoryEntries()
123+
.withEntry(classFileEntry(SomeEnum.class).toAbsolutePath())
124+
.create();
125+
URL jarUrl = getJarUrlOf(jarFile);
126+
127+
Thread.currentThread().setContextClassLoader(new CustomClassLoader(jarUrl));
128+
129+
String packageName = SomeEnum.class.getPackage().getName();
130+
assertThat(Locations.ofPackage(packageName))
131+
.as("Locations of package '%s'", packageName)
132+
.noneMatch(it -> it.contains(jarUrl.toString()));
133+
}
134+
74135
@Test
75136
public void locations_of_packages_from_mixed_URIs() {
76137
Set<Location> locations = Locations.ofPackage("com.tngtech");
@@ -104,6 +165,10 @@ public void locations_in_classpath() throws Exception {
104165
);
105166
}
106167

168+
private static URL getJarUrlOf(JarFile jarFile) throws MalformedURLException {
169+
return jarUriOfEntry(jarFile, "").toURL();
170+
}
171+
107172
private Iterable<URI> urisOf(Collection<Location> locations) {
108173
return locations.stream().map(Location::asURI).collect(toSet());
109174
}
@@ -119,4 +184,33 @@ private URI uriOfFolderOf(Class<?> clazz) throws Exception {
119184
String urlAsString = urlOfClass(clazz).toExternalForm();
120185
return new URL(urlAsString.substring(0, urlAsString.lastIndexOf("/")) + "/").toURI();
121186
}
187+
188+
private static <T> Stream<T> stream(Iterable<T> iterable) {
189+
return StreamSupport.stream(iterable.spliterator(), false);
190+
}
191+
192+
private <T> T unchecked(ThrowingSupplier<T> supplier) {
193+
try {
194+
return supplier.get();
195+
} catch (Exception e) {
196+
throw new RuntimeException(e);
197+
}
198+
}
199+
200+
@FunctionalInterface
201+
private interface ThrowingSupplier<T> {
202+
T get() throws Exception;
203+
}
204+
205+
private static class CustomClassLoader extends URLClassLoader {
206+
CustomClassLoader(URL... urls) {
207+
super(urls, null);
208+
}
209+
210+
@Override
211+
public URL[] getURLs() {
212+
// Simulate some non-standard ClassLoader by not exposing any URLs we could retrieve from the outside
213+
return new URL[0];
214+
}
215+
}
122216
}

archunit/src/test/java/com/tngtech/archunit/core/importer/TestJarFile.java

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,23 @@
2121
class TestJarFile {
2222
private final Manifest manifest;
2323
private final Set<String> entries = new HashSet<>();
24+
private boolean withDirectoryEntries = false;
2425

2526
TestJarFile() {
2627
manifest = new Manifest();
2728
manifest.getMainAttributes().put(MANIFEST_VERSION, "1.0");
2829
}
2930

31+
public TestJarFile withDirectoryEntries() {
32+
withDirectoryEntries = true;
33+
return this;
34+
}
35+
36+
public TestJarFile withoutDirectoryEntries() {
37+
withDirectoryEntries = false;
38+
return this;
39+
}
40+
3041
TestJarFile withManifestAttribute(Attributes.Name name, String value) {
3142
manifest.getMainAttributes().put(name, value);
3243
return this;
@@ -56,8 +67,9 @@ private String createAndReturnName(Supplier<JarFile> createJarFile) {
5667
}
5768

5869
JarFile create(File jarFile) {
70+
Set<String> allEntries = withDirectoryEntries ? ensureDirectoryEntries(entries) : entries;
5971
try (JarOutputStream jarOut = new JarOutputStream(newOutputStream(jarFile.toPath()), manifest)) {
60-
for (String entry : entries) {
72+
for (String entry : allEntries) {
6173
write(jarOut, entry);
6274
}
6375
} catch (IOException e) {
@@ -66,6 +78,27 @@ JarFile create(File jarFile) {
6678
return newJarFile(jarFile);
6779
}
6880

81+
private Set<String> ensureDirectoryEntries(Set<String> entries) {
82+
Set<String> result = new HashSet<>();
83+
entries.forEach(entry -> {
84+
result.addAll(createDirectoryEntries(entry));
85+
result.add(entry);
86+
});
87+
return result;
88+
}
89+
90+
private static Set<String> createDirectoryEntries(String entry) {
91+
Set<String> result = new HashSet<>();
92+
int checkedUpToIndex = -1;
93+
do {
94+
checkedUpToIndex = entry.indexOf("/", checkedUpToIndex + 1);
95+
if (checkedUpToIndex != -1) {
96+
result.add(entry.substring(0, checkedUpToIndex + 1));
97+
}
98+
} while (checkedUpToIndex != -1);
99+
return result;
100+
}
101+
69102
String createAndReturnName(File jarFile) {
70103
return createAndReturnName(() -> create(jarFile));
71104
}

0 commit comments

Comments
 (0)