From cc3fe128463014f92b73e4ca5a48b1434e00ba15 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 24 Jan 2024 07:50:53 +0100 Subject: [PATCH 1/4] Prepare issue branch. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 79f4bd910c..086770e910 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 3.2.3-SNAPSHOT + 3.2.x-3033-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. From d7d3f7a0038d7a0dd234bd4b4ef79ebacd721658 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 24 Jan 2024 11:28:23 +0100 Subject: [PATCH 2/4] Guard AOT registration of PageModule. This commit makes sure to only register runtime hints for PageModule if Jackson is present. --- pom.xml | 5 + .../data/web/aot/WebRuntimeHints.java | 7 +- .../data/test/util/ClassPathExclusions.java | 45 ++++++ .../util/ClassPathExclusionsExtension.java | 129 ++++++++++++++++ .../util/PackageExcludingClassLoader.java | 142 ++++++++++++++++++ .../web/aot/WebRuntimeHintsUnitTests.java | 49 ++++++ 6 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/springframework/data/test/util/ClassPathExclusions.java create mode 100644 src/test/java/org/springframework/data/test/util/ClassPathExclusionsExtension.java create mode 100644 src/test/java/org/springframework/data/test/util/PackageExcludingClassLoader.java create mode 100644 src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java diff --git a/pom.xml b/pom.xml index 086770e910..1d7189dab2 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 1230a22017..f791ce4981 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.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 0000000000..1f903be701 --- /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 0000000000..295ae2d24e --- /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 0000000000..b389354b31 --- /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 0000000000..453eabe7ad --- /dev/null +++ b/src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java @@ -0,0 +1,49 @@ +/* + * 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 org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.aot.hint.ReflectionHints; +import org.springframework.aot.hint.RuntimeHints; +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()); + } + + @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()); + } +} From 9599ac24d1e26dc53f1b612c5355d4660431b714 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 24 Jan 2024 11:46:12 +0100 Subject: [PATCH 3/4] Polishing. Use newly introduced ClassPathExclusions instead of manually creating the ClassLoader. --- ...nableSpringDataWebSupportIntegrationTests.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 98b8f22bb6..bf2087beb7 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.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 @@ * @author Jens Schauder * @author Vedran Pavic * @author Yanming Zhou + * @author Christoph Strobl */ class EnableSpringDataWebSupportIntegrationTests { @@ -136,11 +134,10 @@ void registersHateoasSpecificBeanDefinitions() throws Exception { } @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 @@ void registersJacksonSpecificBeanDefinitions() throws Exception { } @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()); From 4706e952d90347a5aaa73322a893fcc6354956de Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 24 Jan 2024 15:09:46 +0100 Subject: [PATCH 4/4] Add missing assertions --- .../data/web/aot/WebRuntimeHintsUnitTests.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java b/src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java index 453eabe7ad..4cb0ffc375 100644 --- a/src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java +++ b/src/test/java/org/springframework/data/web/aot/WebRuntimeHintsUnitTests.java @@ -15,10 +15,14 @@ */ 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; /** @@ -34,6 +38,9 @@ void shouldRegisterRuntimeHintWhenJacksonPresent() { 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 @@ -45,5 +52,7 @@ void shouldRegisterRuntimeHintWithTypeNameWhenJacksonNotPresent() { Mockito.when(runtimeHints.reflection()).thenReturn(reflectionHints); new WebRuntimeHints().registerHints(runtimeHints, this.getClass().getClassLoader()); + + assertThat(runtimeHints.reflection().typeHints()).isEmpty(); } }