From 1062f53bfaada6b0b84761e837c3b196ca4d1216 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Wed, 4 Sep 2024 08:51:19 +0200 Subject: [PATCH] GH-613 - Add SPI to support external ApplicationModuleSource contributions. We now expose ApplicationModuleSourceFactory as Spring Factories-based SPI interface to further contribute ApplicationModuleSource instances either from a provided root package subject for module detection through a (potentially customized) ApplicationModuleDetectionStrategy or by explicitly listing particular module base packages. --- .../modulith/core/ApplicationModule.java | 4 +- .../core/ApplicationModuleSource.java | 65 +++++++- .../ApplicationModuleSourceContributions.java | 115 +++++++++++++ .../core/ApplicationModuleSourceFactory.java | 137 +++++++++++++++ .../modulith/core/ApplicationModules.java | 31 ++-- .../ApplicationModuleSourceContribution.java | 56 +++++++ .../contributed/detected/package-info.java | 2 + .../contributed/enumerated/package-info.java | 1 + ...ionModuleSourceContributionsUnitTests.java | 52 ++++++ .../modulith/core/TestUtils.java | 2 +- .../ApplicationModuleSourceContribution.java | 56 +++++++ .../detected/DetectedContribution.java | 24 +++ .../contributed/detected/package-info.java | 2 + .../enumerated/EnumeratedContribution.java | 24 +++ .../contributed/enumerated/package-info.java | 1 + .../resources/META-INF/spring-test.factories | 1 + .../ApplicationModulesIntegrationTest.java | 19 +++ .../modulith/test/TestUtils.java | 26 +++ .../modules/ROOT/pages/fundamentals.adoc | 157 +++++++++++------- 19 files changed, 697 insertions(+), 78 deletions(-) create mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSourceContributions.java create mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSourceFactory.java create mode 100644 spring-modulith-core/src/test/java/contributed/ApplicationModuleSourceContribution.java create mode 100644 spring-modulith-core/src/test/java/contributed/detected/package-info.java create mode 100644 spring-modulith-core/src/test/java/contributed/enumerated/package-info.java create mode 100644 spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModuleSourceContributionsUnitTests.java create mode 100644 spring-modulith-integration-test/src/main/java/contributed/ApplicationModuleSourceContribution.java create mode 100644 spring-modulith-integration-test/src/main/java/contributed/detected/DetectedContribution.java create mode 100644 spring-modulith-integration-test/src/main/java/contributed/detected/package-info.java create mode 100644 spring-modulith-integration-test/src/main/java/contributed/enumerated/EnumeratedContribution.java create mode 100644 spring-modulith-integration-test/src/main/java/contributed/enumerated/package-info.java create mode 100644 spring-modulith-integration-test/src/main/resources/META-INF/spring-test.factories diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java index 74ec2fd0..cd1fad9e 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModule.java @@ -102,7 +102,7 @@ public class ApplicationModule implements Comparable { Assert.notNull(source, "Base package must not be null!"); Assert.notNull(exclusions, "Exclusions must not be null!"); - JavaPackage basePackage = source.moduleBasePackage(); + JavaPackage basePackage = source.getModuleBasePackage(); this.source = source; this.basePackage = basePackage; @@ -144,7 +144,7 @@ public class ApplicationModule implements Comparable { * @return will never be {@literal null} or empty. */ public String getName() { - return source.moduleName(); + return source.getModuleName(); } /** diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSource.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSource.java index d26d75c9..4c315f39 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSource.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSource.java @@ -15,6 +15,7 @@ */ package org.springframework.modulith.core; +import java.util.Objects; import java.util.stream.Stream; import org.springframework.util.Assert; @@ -29,9 +30,25 @@ import org.springframework.util.Assert; * @author Oliver Drotbohm * @since 1.3 */ -record ApplicationModuleSource( - JavaPackage moduleBasePackage, - String moduleName) { +public class ApplicationModuleSource { + + private final JavaPackage moduleBasePackage; + private final String moduleName; + + /** + * Creates a new {@link ApplicationModuleSource} for the given module base package and module name. + * + * @param moduleBasePackage must not be {@literal null}. + * @param moduleName must not be {@literal null} or empty. + */ + private ApplicationModuleSource(JavaPackage moduleBasePackage, String moduleName) { + + Assert.notNull(moduleBasePackage, "JavaPackage must not be null!"); + Assert.hasText(moduleName, "Module name must not be null or empty!"); + + this.moduleBasePackage = moduleBasePackage; + this.moduleName = moduleName; + } /** * Returns a {@link Stream} of {@link ApplicationModuleSource}s by applying the given @@ -66,4 +83,46 @@ record ApplicationModuleSource( return new ApplicationModuleSource(pkg, name); } + + /** + * @return will never be {@literal null}. + */ + public JavaPackage getModuleBasePackage() { + return moduleBasePackage; + } + + /** + * @return will never be {@literal null} or empty. + */ + public String getModuleName() { + return moduleName; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object obj) { + + if (obj == this) { + return true; + } + + if (!(obj instanceof ApplicationModuleSource that)) { + return false; + } + + return Objects.equals(this.moduleName, that.moduleName) + && Objects.equals(this.moduleBasePackage, that.moduleBasePackage); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return Objects.hash(moduleName, moduleBasePackage); + } } diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSourceContributions.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSourceContributions.java new file mode 100644 index 00000000..f980f654 --- /dev/null +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSourceContributions.java @@ -0,0 +1,115 @@ +/* + * 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.modulith.core; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.springframework.core.io.support.SpringFactoriesLoader; + +import com.tngtech.archunit.core.domain.JavaClasses; + +/** + * Lookup of external {@link ApplicationModuleSource} contributions via {@link ApplicationModuleSourceFactory} + * implementations. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +class ApplicationModuleSourceContributions { + + static String LOCATION = SpringFactoriesLoader.FACTORIES_RESOURCE_LOCATION; + + private final List rootPackages; + private final List sources; + + /** + * Creates a new {@link ApplicationModuleSourceContributions} for the given importer function, default + * {@link ApplicationModuleDetectionStrategy} and whether to use fully-qualified module names. + * + * @param importer must not be {@literal null}. + * @param defaultStrategy must not be {@literal null}. + * @param useFullyQualifiedModuleNames whether to use fully-qualified module names. + */ + public static ApplicationModuleSourceContributions of(Function, JavaClasses> importer, + ApplicationModuleDetectionStrategy defaultStrategy, boolean useFullyQualifiedModuleNames) { + + var loader = SpringFactoriesLoader.forResourceLocation(LOCATION, ApplicationModules.class.getClassLoader()); + + return new ApplicationModuleSourceContributions(loader.load(ApplicationModuleSourceFactory.class), importer, + defaultStrategy, useFullyQualifiedModuleNames); + } + + /** + * Creates a new {@link ApplicationModuleSourceContributions} for the given {@link ApplicationModuleSourceFactory}s, + * importer function, default {@link ApplicationModuleDetectionStrategy} and whether to use fully-qualified module + * names. + * + * @param factories must not be {@literal null}. + * @param importer must not be {@literal null}. + * @param defaultStrategy must not be {@literal null}. + * @param useFullyQualifiedModuleNames whether to use fully-qualified module names. + */ + ApplicationModuleSourceContributions(List factories, + Function, JavaClasses> importer, + ApplicationModuleDetectionStrategy defaultStrategy, boolean useFullyQualifiedModuleNames) { + + this.rootPackages = new ArrayList<>(); + this.sources = new ArrayList<>(); + + factories.forEach(factory -> { + + var contributedPackages = factory.getRootPackages(); + var factoryStrategy = factory.getApplicationModuleDetectionStrategy(); + var classes = importer.apply(contributedPackages); + var strategy = factoryStrategy == null ? defaultStrategy : factoryStrategy; + + // Add discovered ApplicationModuleSources + + rootPackages.addAll(contributedPackages); + + contributedPackages.stream() + .map(it -> JavaPackage.of(Classes.of(classes), it)) + .flatMap(it -> factory.getApplicationModuleSources(it, strategy, useFullyQualifiedModuleNames)) + .forEach(this.sources::add); + + // Add enumerated ApplicationModuleSources + + Function packageRegistrar = it -> { + return JavaPackage.of(Classes.of(importer.apply(List.of(it))), it); + }; + + factory.getApplicationModuleSources(packageRegistrar, useFullyQualifiedModuleNames).forEach(this.sources::add); + }); + } + + /** + * @return will never be {@literal null}. + */ + public Stream getRootPackages() { + return rootPackages.stream(); + } + + /** + * @return will never be {@literal null}. + */ + public Stream getSources() { + return sources.stream(); + } +} diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSourceFactory.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSourceFactory.java new file mode 100644 index 00000000..4fda0be2 --- /dev/null +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleSourceFactory.java @@ -0,0 +1,137 @@ +/* + * 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.modulith.core; + +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.springframework.lang.Nullable; + +/** + * SPI to allow build units contribute additional {@link ApplicationModuleSource}s in the form of either declaring them + * directly via {@link #getModuleBasePackages()} and {@link #getApplicationModuleSources(Function, boolean)} or via + * provided {@link #getRootPackages()} and subsequent resolution via + * {@link #getApplicationModuleSources(JavaPackage, ApplicationModuleDetectionStrategy, boolean)} for each of the + * packages provided.
+ * The following snippet would register {@link ApplicationModuleSource}s for {@code com.acme.foo} and + * {@code com.acme.bar} directly: + * + *
+ * {@code
+ * class MyCustomFactory implements ApplicationModuleSourceFactory {
+ *
+ * 	@Override
+ * 	public List getModuleBasePackages() {
+ * 		return List.of("com.acme.foo", "com.acme.bar");
+ * 	}
+ * }
+ * }
+ * 
+ * + * The following snippet would register all modules located underneath {@code com.acme} found via the + * {@link ApplicationModuleDetectionStrategy#explicitlyAnnotated()} strategy: + * + *
+ * {@code
+ * class MyCustomFactory implements ApplicationModuleSourceFactory {
+ *
+ * 	@Override
+ * 	public List getRootPackages() {
+ * 		return List.of("com.acme");
+ * 	}
+ *
+ * 	@Override
+ * 	ApplicationModuleDetectionStrategy getApplicationModuleDetectionStrategy() {
+ * 		return ApplicationModuleDetectionStrategy.explicitlyAnnotated();
+ * 	}
+ * }
+ * }
+ * 
+ * + * @author Oliver Drotbohm + * @since 1.3 + */ +public interface ApplicationModuleSourceFactory { + + /** + * Returns the additional root packages to be considered. The ones returned from this method will be scanned for + * {@link ApplicationModuleSource}s via + * {@link #getApplicationModuleSources(JavaPackage, ApplicationModuleDetectionStrategy, boolean)} using the + * {@link ApplicationModuleDetectionStrategy} returned from {@link #getApplicationModuleDetectionStrategy()}. If the + * latter is {@literal null}, the default {@link ApplicationModuleDetectionStrategy} is used. + * + * @return must not be {@literal null}. + */ + default List getRootPackages() { + return Collections.emptyList(); + } + + /** + * Returns additional module base packages to create {@link ApplicationModuleSource}s from. Subsequently handled by + * {@link #getApplicationModuleSources(Function, boolean)}. + * + * @return must not be {@literal null}. + */ + default List getModuleBasePackages() { + return Collections.emptyList(); + } + + /** + * Returns the {@link ApplicationModuleDetectionStrategy} to be used to detect {@link ApplicationModuleSource}s from + * the packages returned by {@link #getRootPackages()}. If {@literal null} is returned, the default + * {@link ApplicationModuleDetectionStrategy} will be used. + * + * @return can be {@literal null}. + */ + @Nullable + default ApplicationModuleDetectionStrategy getApplicationModuleDetectionStrategy() { + return null; + } + + /** + * Creates all {@link ApplicationModuleSource}s using the given base package and + * {@link ApplicationModuleDetectionStrategy}. + * + * @param rootPackage will never be {@literal null}. + * @param strategy will never be {@literal null}. + * @param useFullyQualifiedModuleNames whether to use fully-qualified names for application modules. + * @return must not be {@literal null}. + * @see ApplicationModuleSource#from(JavaPackage, ApplicationModuleDetectionStrategy, boolean) + */ + default Stream getApplicationModuleSources(JavaPackage rootPackage, + ApplicationModuleDetectionStrategy strategy, boolean useFullyQualifiedModuleNames) { + + return ApplicationModuleSource.from(rootPackage, strategy, useFullyQualifiedModuleNames); + } + + /** + * Creates {@link ApplicationModuleSource} for individually, manually described application modules. + * + * @param packages will never be {@literal null}. + * @param useFullyQualifiedModuleNames whether to use fully-qualified names for application modules. + * @return must not be {@literal null}. + * @see ApplicationModuleSource#from(JavaPackage, String) + */ + default Stream getApplicationModuleSources(Function packages, + boolean useFullyQualifiedModuleNames) { + + return getModuleBasePackages().stream() + .map(packages) + .map(it -> ApplicationModuleSource.from(it, useFullyQualifiedModuleNames ? it.getName() : it.getLocalName())); + } +} diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java index 07fb0ec8..49fd82f2 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModules.java @@ -96,12 +96,11 @@ public class ApplicationModules implements Iterable { * * @param metadata must not be {@literal null}. * @param ignored must not be {@literal null}. - * @param useFullyQualifiedModuleNames can be {@literal null}. * @param option must not be {@literal null}. */ protected ApplicationModules(ModulithMetadata metadata, - DescribedPredicate ignored, boolean useFullyQualifiedModuleNames, ImportOption option) { - this(metadata, metadata.getBasePackages(), ignored, useFullyQualifiedModuleNames, option); + DescribedPredicate ignored, ImportOption option) { + this(metadata, metadata.getBasePackages(), ignored, metadata.useFullyQualifiedModuleNames(), option); } /** @@ -113,8 +112,7 @@ public class ApplicationModules implements Iterable { * @param useFullyQualifiedModuleNames can be {@literal null}. * @param option must not be {@literal null}. * @deprecated since 1.2, for removal in 1.3. Use {@link ApplicationModules(ModulithMetadata, DescribedPredicate, - * boolean, ImportOption)} instead and set up {@link ModulithMetadata} to contain the packages you want to - * use. + * ImportOption)} instead and set up {@link ModulithMetadata} to contain the packages you want to use. */ @Deprecated(forRemoval = true) protected ApplicationModules(ModulithMetadata metadata, Collection packages, @@ -128,8 +126,9 @@ public class ApplicationModules implements Iterable { DescribedPredicate excluded = DescribedPredicate.or(ignored, IS_AOT_TYPE, IS_SPRING_CGLIB_PROXY); this.metadata = metadata; - this.allClasses = new ClassFileImporter() // - .withImportOption(option) // + var importer = new ClassFileImporter() // + .withImportOption(option); + this.allClasses = importer // .importPackages(packages) // .that(not(excluded)); @@ -138,9 +137,14 @@ public class ApplicationModules implements Iterable { Classes classes = Classes.of(allClasses); var strategy = ApplicationModuleDetectionStrategyLookup.getStrategy(); - var sources = packages.stream() // + var contributions = ApplicationModuleSourceContributions.of( + pkgs -> importer.importPackages(pkgs).that(not(excluded)), strategy, useFullyQualifiedModuleNames); + + var directSources = packages.stream() // .map(it -> JavaPackage.of(classes, it)) - .flatMap(it -> ApplicationModuleSource.from(it, strategy, useFullyQualifiedModuleNames)) + .flatMap(it -> ApplicationModuleSource.from(it, strategy, useFullyQualifiedModuleNames)); + + var sources = Stream.concat(directSources, contributions.getSources()) .distinct() .collect(Collectors.toUnmodifiableSet()); @@ -148,12 +152,12 @@ public class ApplicationModules implements Iterable { .map(it -> { return new ApplicationModule(it, - JavaPackages.onlySubPackagesOf(it.moduleBasePackage(), - sources.stream().map(ApplicationModuleSource::moduleBasePackage).toList())); // + JavaPackages.onlySubPackagesOf(it.getModuleBasePackage(), + sources.stream().map(ApplicationModuleSource::getModuleBasePackage).toList())); // }) .collect(toMap(ApplicationModule::getName, Function.identity())); - this.rootPackages = packages.stream() // + this.rootPackages = Stream.concat(packages.stream(), contributions.getRootPackages()) // .map(it -> JavaPackage.of(classes, it).toSingle()) // .toList(); @@ -643,8 +647,7 @@ public class ApplicationModules implements Iterable { return CACHE.computeIfAbsent(cacheKey, key -> { var metadata = key.getMetadata(); - var modules = new ApplicationModules(metadata, key.getIgnored(), - metadata.useFullyQualifiedModuleNames(), key.getOptions()); + var modules = new ApplicationModules(metadata, key.getIgnored(), key.getOptions()); var sharedModules = metadata.getSharedModuleNames() // .map(modules::getRequiredModule) // diff --git a/spring-modulith-core/src/test/java/contributed/ApplicationModuleSourceContribution.java b/spring-modulith-core/src/test/java/contributed/ApplicationModuleSourceContribution.java new file mode 100644 index 00000000..89a6a4f6 --- /dev/null +++ b/spring-modulith-core/src/test/java/contributed/ApplicationModuleSourceContribution.java @@ -0,0 +1,56 @@ +/* + * 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 contributed; + +import java.util.List; + +import org.springframework.modulith.core.ApplicationModuleDetectionStrategy; +import org.springframework.modulith.core.ApplicationModuleSourceFactory; + +/** + * Example {@link ApplicationModuleSourceFactory}. + * + * @author Oliver Drotbohm + */ +public class ApplicationModuleSourceContribution implements ApplicationModuleSourceFactory { + + /* + * (non-Javadoc) + * @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getRootPackages() + */ + @Override + public List getRootPackages() { + return List.of("contributed"); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getApplicationModuleDetectionStrategy() + */ + @Override + public ApplicationModuleDetectionStrategy getApplicationModuleDetectionStrategy() { + return ApplicationModuleDetectionStrategy.explicitlyAnnotated(); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getModuleBasePackages() + */ + @Override + public List getModuleBasePackages() { + return List.of("contributed.enumerated"); + } +} diff --git a/spring-modulith-core/src/test/java/contributed/detected/package-info.java b/spring-modulith-core/src/test/java/contributed/detected/package-info.java new file mode 100644 index 00000000..365d8162 --- /dev/null +++ b/spring-modulith-core/src/test/java/contributed/detected/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.ApplicationModule +package contributed.detected; diff --git a/spring-modulith-core/src/test/java/contributed/enumerated/package-info.java b/spring-modulith-core/src/test/java/contributed/enumerated/package-info.java new file mode 100644 index 00000000..cbcccf42 --- /dev/null +++ b/spring-modulith-core/src/test/java/contributed/enumerated/package-info.java @@ -0,0 +1 @@ +package contributed.enumerated; diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModuleSourceContributionsUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModuleSourceContributionsUnitTests.java new file mode 100644 index 00000000..f956333a --- /dev/null +++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModuleSourceContributionsUnitTests.java @@ -0,0 +1,52 @@ +/* + * 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.modulith.core; + +import static org.assertj.core.api.Assertions.*; + +import contributed.ApplicationModuleSourceContribution; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; + +/** + * Unit tests for {@link ApplicationModuleSourceContributions}. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +class ApplicationModuleSourceContributionsUnitTests { + + @Test // GH-613 + void detectsContributions() { + + var factories = List.of(new ApplicationModuleSourceContribution()); + var importer = new ClassFileImporter().withImportOption(new ImportOption.OnlyIncludeTests()); + var strategy = ApplicationModuleDetectionStrategy.directSubPackage(); + + var contributions = new ApplicationModuleSourceContributions(factories, importer::importPackages, strategy, false); + + assertThat(contributions.getRootPackages()).contains("contributed"); + assertThat(contributions.getSources()) + .extracting(ApplicationModuleSource::getModuleBasePackage) + .extracting(JavaPackage::getName) + .containsExactlyInAnyOrder("contributed.detected", "contributed.enumerated"); + } +} diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java index d2afd0e8..3273fd97 100644 --- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java +++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java @@ -110,6 +110,6 @@ public class TestUtils { } private static ApplicationModules of(ModulithMetadata metadata, DescribedPredicate ignores) { - return new ApplicationModules(metadata, ignores, false, new ImportOption.OnlyIncludeTests()) {}; + return new ApplicationModules(metadata, ignores, new ImportOption.OnlyIncludeTests()) {}; } } diff --git a/spring-modulith-integration-test/src/main/java/contributed/ApplicationModuleSourceContribution.java b/spring-modulith-integration-test/src/main/java/contributed/ApplicationModuleSourceContribution.java new file mode 100644 index 00000000..89a6a4f6 --- /dev/null +++ b/spring-modulith-integration-test/src/main/java/contributed/ApplicationModuleSourceContribution.java @@ -0,0 +1,56 @@ +/* + * 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 contributed; + +import java.util.List; + +import org.springframework.modulith.core.ApplicationModuleDetectionStrategy; +import org.springframework.modulith.core.ApplicationModuleSourceFactory; + +/** + * Example {@link ApplicationModuleSourceFactory}. + * + * @author Oliver Drotbohm + */ +public class ApplicationModuleSourceContribution implements ApplicationModuleSourceFactory { + + /* + * (non-Javadoc) + * @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getRootPackages() + */ + @Override + public List getRootPackages() { + return List.of("contributed"); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getApplicationModuleDetectionStrategy() + */ + @Override + public ApplicationModuleDetectionStrategy getApplicationModuleDetectionStrategy() { + return ApplicationModuleDetectionStrategy.explicitlyAnnotated(); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getModuleBasePackages() + */ + @Override + public List getModuleBasePackages() { + return List.of("contributed.enumerated"); + } +} diff --git a/spring-modulith-integration-test/src/main/java/contributed/detected/DetectedContribution.java b/spring-modulith-integration-test/src/main/java/contributed/detected/DetectedContribution.java new file mode 100644 index 00000000..e07de31b --- /dev/null +++ b/spring-modulith-integration-test/src/main/java/contributed/detected/DetectedContribution.java @@ -0,0 +1,24 @@ +/* + * 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 contributed.detected; + +/** + * + * @author Oliver Drotbohm + */ +public class DetectedContribution { + +} diff --git a/spring-modulith-integration-test/src/main/java/contributed/detected/package-info.java b/spring-modulith-integration-test/src/main/java/contributed/detected/package-info.java new file mode 100644 index 00000000..365d8162 --- /dev/null +++ b/spring-modulith-integration-test/src/main/java/contributed/detected/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.ApplicationModule +package contributed.detected; diff --git a/spring-modulith-integration-test/src/main/java/contributed/enumerated/EnumeratedContribution.java b/spring-modulith-integration-test/src/main/java/contributed/enumerated/EnumeratedContribution.java new file mode 100644 index 00000000..0c3a58bc --- /dev/null +++ b/spring-modulith-integration-test/src/main/java/contributed/enumerated/EnumeratedContribution.java @@ -0,0 +1,24 @@ +/* + * 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 contributed.enumerated; + +/** + * + * @author Oliver Drotbohm + */ +public class EnumeratedContribution { + +} diff --git a/spring-modulith-integration-test/src/main/java/contributed/enumerated/package-info.java b/spring-modulith-integration-test/src/main/java/contributed/enumerated/package-info.java new file mode 100644 index 00000000..cbcccf42 --- /dev/null +++ b/spring-modulith-integration-test/src/main/java/contributed/enumerated/package-info.java @@ -0,0 +1 @@ +package contributed.enumerated; diff --git a/spring-modulith-integration-test/src/main/resources/META-INF/spring-test.factories b/spring-modulith-integration-test/src/main/resources/META-INF/spring-test.factories new file mode 100644 index 00000000..ff7aec2c --- /dev/null +++ b/spring-modulith-integration-test/src/main/resources/META-INF/spring-test.factories @@ -0,0 +1 @@ +org.springframework.modulith.core.ApplicationModuleSourceFactory=contributed.ApplicationModuleSourceContribution diff --git a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/core/ApplicationModulesIntegrationTest.java b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/core/ApplicationModulesIntegrationTest.java index ce9b0450..62e20c02 100644 --- a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/core/ApplicationModulesIntegrationTest.java +++ b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/core/ApplicationModulesIntegrationTest.java @@ -242,6 +242,25 @@ class ApplicationModulesIntegrationTest { assertThatIllegalArgumentException().isThrownBy(() -> ApplicationModules.of("non.existant")); } + @Test // GH-613 + void detectsContributedApplicationModules() { + + var location = ApplicationModuleSourceContributions.LOCATION; + + ApplicationModuleSourceContributions.LOCATION = "META-INF/spring-test.factories"; + + try { + + var modules = org.springframework.modulith.test.TestUtils.createApplicationModules(Application.class); + + assertThat(modules.getModuleByName("detected")).isNotEmpty(); + assertThat(modules.getModuleByName("enumerated")).isNotEmpty(); + + } finally { + ApplicationModuleSourceContributions.LOCATION = location; + } + } + private static void verifyNamedInterfaces(NamedInterfaces interfaces, String name, Class... types) { Stream.of(types).forEach(type -> { diff --git a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/test/TestUtils.java b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/test/TestUtils.java index e361e093..de8aac99 100644 --- a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/test/TestUtils.java +++ b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/test/TestUtils.java @@ -23,6 +23,7 @@ import org.springframework.boot.test.context.SpringBootContextLoader; import org.springframework.boot.test.context.SpringBootTestContextBootstrapper; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.modulith.core.ApplicationModules; import org.springframework.test.context.BootstrapContext; import org.springframework.test.context.CacheAwareContextLoaderDelegate; import org.springframework.test.context.ContextLoadException; @@ -30,11 +31,25 @@ import org.springframework.test.context.MergedContextConfiguration; import org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate; import org.springframework.test.context.support.DefaultBootstrapContext; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; + /** * @author Oliver Drotbohm */ public class TestUtils { + /** + * Creates a fresh {@link ApplicationModules} instance no matter the caching state. + * + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + public static ApplicationModules createApplicationModules(Class type) { + return ApplicationModules.of(type, cacheInvalidator()); + } + public static void assertDependencyMissing(Class testClass, Class expectedMissingDependency) { CacheAwareContextLoaderDelegate delegate = new DefaultCacheAwareContextLoaderDelegate(); @@ -72,4 +87,15 @@ public class TestUtils { private static RuntimeException asRuntimeException(Throwable o_O) { return o_O instanceof RuntimeException ? (RuntimeException) o_O : new RuntimeException(o_O); } + + private static DescribedPredicate cacheInvalidator() { + + return new DescribedPredicate("invalidator") { + + @Override + public boolean test(JavaClass t) { + return false; + } + }; + } } diff --git a/src/docs/antora/modules/ROOT/pages/fundamentals.adoc b/src/docs/antora/modules/ROOT/pages/fundamentals.adoc index 34aab768..26b3d992 100644 --- a/src/docs/antora/modules/ROOT/pages/fundamentals.adoc +++ b/src/docs/antora/modules/ROOT/pages/fundamentals.adoc @@ -1,5 +1,6 @@ [[fundamentals]] = Fundamentals +:tabsize: 2 :toc: :toclevels: 3 @@ -393,64 +394,6 @@ package example.inventory ---- ====== -[[customizing-modules]] -=== Customizing Module Detection - -By default, application modules will be expected to be located in direct sub-packages of the package the Spring Boot application class resides in. -An alternative detection strategy can be activated to only consider package explicitly annotated, either via Spring Modulith's `@ApplicationModule` or jMolecules `@Module` annotation. -That strategy can be activated by configuring the `spring.modulith.detection-strategy` to `explicitly-annotated`. - -.Switching the application module detection strategy to only consider annotated packages -[source, text] ----- -spring.modulith.detection-strategy=explicitly-annotated ----- - -If the neither default application module detection strategy nor the manually annotated one does not work for your application, the detection of the modules can be customized by providing an implementation of `ApplicationModuleDetectionStrategy`. -That interface exposes a single method `Stream getModuleBasePackages(JavaPackage)` and will be called with the package the Spring Boot application class resides in. -You can then inspect the packages residing within that and select the ones to be considered application module base packages based on a naming convention or the like. - -Assume you declare a custom `ApplicationModuleDetectionStrategy` implementation like this: - -.Implementing a custom `ApplicationModuleDetectionStrategy` -[tabs] -====== -Java:: -+ -[source, java, role="primary"] ----- -package example; - -class CustomApplicationModuleDetectionStrategy implements ApplicationModuleDetectionStrategy { - - @Override - public Stream getModuleBasePackages(JavaPackage basePackage) { - // Your module detection goes here - } -} ----- -Kotlin:: -+ -[source, kotlin, role="secondary"] ----- -package example - -class CustomApplicationModuleDetectionStrategy : ApplicationModuleDetectionStrategy { - - override fun getModuleBasePackages(basePackage: JavaPackage): Stream { - // Your module detection goes here - } -} ----- -====== - -This class can now be registered as `spring.modulith.detection-strategy` as follows: - -[source, text] ----- -spring.modulith.detection-strategy=example.CustomApplicationModuleDetectionStrategy ----- - [[customizing-modules-arrangement]] == Customizing the Application Modules Arrangement @@ -514,3 +457,101 @@ The annotation exposes the following attributes to customize: |Instructs Spring Modulith to treat the configured packages as additional root application packages. In other words, application module detection will be triggered for those as well. |=== + +[[customizing-modules]] +=== Customizing Module Detection + +By default, application modules will be expected to be located in direct sub-packages of the package the Spring Boot application class resides in. +An alternative detection strategy can be activated to only consider package explicitly annotated, either via Spring Modulith's `@ApplicationModule` or jMolecules `@Module` annotation. +That strategy can be activated by configuring the `spring.modulith.detection-strategy` to `explicitly-annotated`. + +.Switching the application module detection strategy to only consider annotated packages +[source, text] +---- +spring.modulith.detection-strategy=explicitly-annotated +---- + +If the neither default application module detection strategy nor the manually annotated one does not work for your application, the detection of the modules can be customized by providing an implementation of `ApplicationModuleDetectionStrategy`. +That interface exposes a single method `Stream getModuleBasePackages(JavaPackage)` and will be called with the package the Spring Boot application class resides in. +You can then inspect the packages residing within that and select the ones to be considered application module base packages based on a naming convention or the like. + +Assume you declare a custom `ApplicationModuleDetectionStrategy` implementation like this: + +.Implementing a custom `ApplicationModuleDetectionStrategy` +[tabs] +====== +Java:: ++ +[source, java, role="primary"] +---- +package example; + +class CustomApplicationModuleDetectionStrategy implements ApplicationModuleDetectionStrategy { + + @Override + public Stream getModuleBasePackages(JavaPackage basePackage) { + // Your module detection goes here + } +} +---- +Kotlin:: ++ +[source, kotlin, role="secondary"] +---- +package example + +class CustomApplicationModuleDetectionStrategy : ApplicationModuleDetectionStrategy { + + override fun getModuleBasePackages(basePackage: JavaPackage): Stream { + // Your module detection goes here + } +} +---- +====== + +This class can now be registered as `spring.modulith.detection-strategy` as follows: + +[source, text] +---- +spring.modulith.detection-strategy=example.CustomApplicationModuleDetectionStrategy +---- + +=== Contributing Application Modules From Other Packages + +While `@Modulithic` allows defining `additionalPackages` to trigger application module detection for packages other than the one of the annotated class, its usage requires knowing about those in advance. +As of version 1.3, Spring Modulith supports external contributions of application modules via the `ApplicationModuleSource` and `ApplicationModuleSourceFactory` abstractions. +An implementation of the latter can be registered in a `spring.factories` file located in `META-INF`. + +[source, text] +---- +org.springframework.modulith.core.ApplicationModuleSourceFactory=example.CustomApplicationModuleSourceFactory +---- + +Such a factory can either return arbitrary package names to get an `ApplicationModuleDetectionStrategy` applied, or explicitly return packages to create modules for. + +[source, java] +---- +package example; + +public class CustomApplicationModuleSourceFactory implements ApplicationModuleDetectionStrategy { + + @Override + public List getRootPackages() { + return List.of("com.acme.toscan"); + } + + @Override + public ApplicationModuleDetectionStrategy getApplicationModuleDetectionStrategy() { + return ApplicationModuleDetectionStrategy.explicitlyAnnotated(); + } + + @Override + public List getModuleBasePackages() { + return List.of("com.acme.module"); + } +} +---- + +The above example would use `com.acme.toscan` to detect xref:fundamentals.adoc#customizing-modules[explicitly declared modules] within that and also create an application module from `com.acme.module`. +The package names returned from these will subsequently be translated into ``ApplicationModuleSource``s via the corresponding `getApplicationModuleSource(…)` flavors exposed in `ApplicationModuleDetectionStrategy`. +