diff --git a/pom.xml b/pom.xml index 79f4bd910..91839a953 100644 --- a/pom.xml +++ b/pom.xml @@ -325,6 +325,11 @@ ${jmolecules-integration} true + + org.junit.platform + junit-platform-launcher + test + diff --git a/src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java b/src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java index 1230a2201..f791ce498 100644 --- a/src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java +++ b/src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java @@ -20,6 +20,7 @@ import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.aot.hint.TypeReference; import org.springframework.data.web.config.SpringDataJacksonConfiguration.PageModule; import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; /** * {@link RuntimeHintsRegistrar} providing hints for web usage. @@ -32,7 +33,9 @@ class WebRuntimeHints implements RuntimeHintsRegistrar { @Override public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { - hints.reflection().registerType(TypeReference.of("org.springframework.data.domain.Unpaged"), - hint -> hint.onReachableType(PageModule.class)); + if (ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader)) { + hints.reflection().registerType(TypeReference.of("org.springframework.data.domain.Unpaged"), + hint -> hint.onReachableType(PageModule.class)); + } } } diff --git a/src/test/java/org/springframework/data/test/util/ClassPathExclusions.java b/src/test/java/org/springframework/data/test/util/ClassPathExclusions.java new file mode 100644 index 000000000..1f903be70 --- /dev/null +++ b/src/test/java/org/springframework/data/test/util/ClassPathExclusions.java @@ -0,0 +1,45 @@ +/* + * Copyright 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.data.test.util; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Annotation used to exclude entries from the classpath. + * Simplified version of ClassPathExclusions. + * + * @author Christoph Strobl + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Documented +@ExtendWith(ClassPathExclusionsExtension.class) +public @interface ClassPathExclusions { + + /** + * One or more packages that should be excluded from the classpath. + * + * @return the excluded packages + */ + String[] packages(); + +} diff --git a/src/test/java/org/springframework/data/test/util/ClassPathExclusionsExtension.java b/src/test/java/org/springframework/data/test/util/ClassPathExclusionsExtension.java new file mode 100644 index 000000000..295ae2d24 --- /dev/null +++ b/src/test/java/org/springframework/data/test/util/ClassPathExclusionsExtension.java @@ -0,0 +1,129 @@ +/* + * Copyright 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.data.test.util; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.TestPlan; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.launcher.listeners.TestExecutionSummary; +import org.springframework.util.CollectionUtils; + +/** + * Simplified version of ModifiedClassPathExtension. + * + * @author Christoph Strobl + */ +class ClassPathExclusionsExtension implements InvocationInterceptor { + + @Override + public void interceptBeforeAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptBeforeEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptAfterEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptAfterAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + interceptMethod(invocation, invocationContext, extensionContext); + } + + @Override + public void interceptTestTemplateMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + interceptMethod(invocation, invocationContext, extensionContext); + } + + private void interceptMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + + if (isModifiedClassPathClassLoader(extensionContext)) { + invocation.proceed(); + return; + } + + Class testClass = extensionContext.getRequiredTestClass(); + Method testMethod = invocationContext.getExecutable(); + PackageExcludingClassLoader modifiedClassLoader = PackageExcludingClassLoader.get(testClass, testMethod); + if (modifiedClassLoader == null) { + invocation.proceed(); + return; + } + invocation.skip(); + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(modifiedClassLoader); + try { + runTest(extensionContext.getUniqueId()); + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + + private void runTest(String testId) throws Throwable { + + LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectUniqueId(testId)).build(); + Launcher launcher = LauncherFactory.create(); + TestPlan testPlan = launcher.discover(request); + SummaryGeneratingListener listener = new SummaryGeneratingListener(); + launcher.registerTestExecutionListeners(listener); + launcher.execute(testPlan); + TestExecutionSummary summary = listener.getSummary(); + if (!CollectionUtils.isEmpty(summary.getFailures())) { + throw summary.getFailures().get(0).getException(); + } + } + + private void intercept(Invocation invocation, ExtensionContext extensionContext) throws Throwable { + if (isModifiedClassPathClassLoader(extensionContext)) { + invocation.proceed(); + return; + } + invocation.skip(); + } + + private boolean isModifiedClassPathClassLoader(ExtensionContext extensionContext) { + Class testClass = extensionContext.getRequiredTestClass(); + ClassLoader classLoader = testClass.getClassLoader(); + return classLoader.getClass().getName().equals(PackageExcludingClassLoader.class.getName()); + } +} diff --git a/src/test/java/org/springframework/data/test/util/PackageExcludingClassLoader.java b/src/test/java/org/springframework/data/test/util/PackageExcludingClassLoader.java new file mode 100644 index 000000000..b389354b3 --- /dev/null +++ b/src/test/java/org/springframework/data/test/util/PackageExcludingClassLoader.java @@ -0,0 +1,142 @@ +/* + * Copyright 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.data.test.util; + +import java.io.File; +import java.lang.management.ManagementFactory; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collector; +import java.util.stream.Stream; + +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.ClassUtils; + +/** + * Simplified version of ModifiedClassPathClassLoader. + * + * @author Christoph Strobl + */ +class PackageExcludingClassLoader extends URLClassLoader { + + private final Set excludedPackages; + private final ClassLoader junitLoader; + + PackageExcludingClassLoader(URL[] urls, ClassLoader parent, Collection excludedPackages, + ClassLoader junitClassLoader) { + + super(urls, parent); + this.excludedPackages = Set.copyOf(excludedPackages); + this.junitLoader = junitClassLoader; + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + + if (name.startsWith("org.junit") || name.startsWith("org.hamcrest")) { + return Class.forName(name, false, this.junitLoader); + } + + String packageName = ClassUtils.getPackageName(name); + if (this.excludedPackages.contains(packageName)) { + throw new ClassNotFoundException(name); + } + return super.loadClass(name); + } + + static PackageExcludingClassLoader get(Class testClass, Method testMethod) { + + List excludedPackages = readExcludedPackages(testClass, testMethod); + + if (excludedPackages.isEmpty()) { + return null; + } + + ClassLoader testClassClassLoader = testClass.getClassLoader(); + Stream urls = null; + if (testClassClassLoader instanceof URLClassLoader urlClassLoader) { + urls = Stream.of(urlClassLoader.getURLs()); + } else { + urls = Stream.of(ManagementFactory.getRuntimeMXBean().getClassPath().split(File.pathSeparator)) + .map(PackageExcludingClassLoader::toURL); + } + + return new PackageExcludingClassLoader(urls.toArray(URL[]::new), testClassClassLoader.getParent(), excludedPackages, + testClassClassLoader); + } + + private static List readExcludedPackages(Class testClass, Method testMethod) { + + return Stream.of( // + AnnotatedElementUtils.findMergedAnnotation(testClass, ClassPathExclusions.class), + AnnotatedElementUtils.findMergedAnnotation(testMethod, ClassPathExclusions.class) // + ).filter(Objects::nonNull) // + .map(ClassPathExclusions::packages) // + .collect(new CombingArrayCollector()); + } + + private static URL toURL(String entry) { + try { + return new File(entry).toURI().toURL(); + } catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } + + private static class CombingArrayCollector implements Collector, List> { + + @Override + public Supplier> supplier() { + return ArrayList::new; + } + + @Override + public BiConsumer, T[]> accumulator() { + return (target, values) -> target.addAll(Arrays.asList(values)); + } + + @Override + public BinaryOperator> combiner() { + return (r1, r2) -> { + r1.addAll(r2); + return r1; + }; + } + + @Override + public Function, List> finisher() { + return i -> (List) i; + } + + @Override + public Set characteristics() { + return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH)); + } + } +} diff --git a/src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java b/src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java new file mode 100644 index 000000000..4cb0ffc37 --- /dev/null +++ b/src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 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.data.web.aot; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.aot.hint.ReflectionHints; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.TypeReference; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.data.test.util.ClassPathExclusions; + +/** + * @author Christoph Strobl + */ +class WebRuntimeHintsUnitTests { + + @Test // GH-3033 + void shouldRegisterRuntimeHintWhenJacksonPresent() { + + ReflectionHints reflectionHints = new ReflectionHints(); + RuntimeHints runtimeHints = Mockito.mock(RuntimeHints.class); + Mockito.when(runtimeHints.reflection()).thenReturn(reflectionHints); + + new WebRuntimeHints().registerHints(runtimeHints, this.getClass().getClassLoader()); + + assertThat(runtimeHints).matches( + RuntimeHintsPredicates.reflection().onType(TypeReference.of("org.springframework.data.domain.Unpaged"))); + } + + @Test // GH-3033 + @ClassPathExclusions(packages = { "com.fasterxml.jackson.databind" }) + void shouldRegisterRuntimeHintWithTypeNameWhenJacksonNotPresent() { + + ReflectionHints reflectionHints = new ReflectionHints(); + RuntimeHints runtimeHints = Mockito.mock(RuntimeHints.class); + Mockito.when(runtimeHints.reflection()).thenReturn(reflectionHints); + + new WebRuntimeHints().registerHints(runtimeHints, this.getClass().getClassLoader()); + + assertThat(runtimeHints.reflection().typeHints()).isEmpty(); + } +} diff --git a/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java b/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java index 98b8f22bb..bf2087beb 100755 --- a/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java +++ b/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java @@ -26,27 +26,24 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.ConversionService; -import org.springframework.data.classloadersupport.HidingClassLoader; import org.springframework.data.geo.Distance; import org.springframework.data.geo.Point; import org.springframework.data.querydsl.EntityPathResolver; import org.springframework.data.querydsl.SimpleEntityPathResolver; import org.springframework.data.querydsl.binding.QuerydslBindingsFactory; +import org.springframework.data.test.util.ClassPathExclusions; import org.springframework.data.web.OffsetScrollPositionHandlerMethodArgumentResolver; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.data.web.PagedResourcesAssemblerArgumentResolver; import org.springframework.data.web.ProxyingHandlerMethodArgumentResolver; import org.springframework.data.web.SortHandlerMethodArgumentResolver; import org.springframework.data.web.WebTestUtils; -import org.springframework.hateoas.Link; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; import org.springframework.web.util.UriComponentsBuilder; -import com.fasterxml.jackson.databind.ObjectMapper; - /** * Integration tests for {@link EnableSpringDataWebSupport}. * @@ -54,6 +51,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; * @author Jens Schauder * @author Vedran Pavic * @author Yanming Zhou + * @author Christoph Strobl */ class EnableSpringDataWebSupportIntegrationTests { @@ -136,11 +134,10 @@ class EnableSpringDataWebSupportIntegrationTests { } @Test // DATACMNS-330 + @ClassPathExclusions(packages = { "org.springframework.hateoas" }) void doesNotRegisterHateoasSpecificComponentsIfHateoasNotPresent() throws Exception { - var classLoader = HidingClassLoader.hide(Link.class); - - ApplicationContext context = WebTestUtils.createApplicationContext(classLoader, SampleConfig.class); + ApplicationContext context = WebTestUtils.createApplicationContext(SampleConfig.class); var names = Arrays.asList(context.getBeanDefinitionNames()); @@ -158,10 +155,10 @@ class EnableSpringDataWebSupportIntegrationTests { } @Test // DATACMNS-475 + @ClassPathExclusions(packages = { "com.fasterxml.jackson.databind" }) void doesNotRegisterJacksonSpecificComponentsIfJacksonNotPresent() throws Exception { - ApplicationContext context = WebTestUtils.createApplicationContext(HidingClassLoader.hide(ObjectMapper.class), - SampleConfig.class); + ApplicationContext context = WebTestUtils.createApplicationContext(SampleConfig.class); var names = Arrays.asList(context.getBeanDefinitionNames());