From 2e31447097ceb01cfb78e343f1c84fbac3d09629 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Wed, 8 Jan 2025 22:29:42 +0100 Subject: [PATCH] GH-1009 - Allow customizing the detection of NamedInterfaces via ApplicationModuleDetectionStrategy. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detection of NamedInterfaces for an application module can now be customized by implementing ApplicationModuleDetectionStrategy.detectNamedInterfaces(…). The default implementation of that methods uses the previously default NamedInterfaces.of(…) lookup. NamedInterfaces and NamedInterface now expose factory methods to be able to construct and combine (through NamedInterfaces.and(…)) instances manually. In particular, the newly introduced NamedInterfaces.builder() method allows setting up a detection configuration that allows picking up packages by naming conventions. --- .../modulith/core/ApplicationModule.java | 4 +- .../ApplicationModuleDetectionStrategy.java | 23 +- .../core/ApplicationModuleSource.java | 27 ++- .../modulith/core/JavaPackage.java | 18 ++ .../modulith/core/NamedInterface.java | 33 ++- .../modulith/core/NamedInterfaces.java | 209 +++++++++++++++++- .../modulith/core/PackageName.java | 23 ++ ...tomApplicationModuleDetectionStrategy.java | 30 +++ .../core/NamedInterfacesUnitTests.java | 32 +++ .../modulith/core/PackageNameUnitTests.java | 17 ++ .../modules/ROOT/pages/fundamentals.adoc | 60 +++++ 11 files changed, 458 insertions(+), 18 deletions(-) create mode 100644 spring-modulith-core/src/test/java/org/springframework/modulith/core/CustomApplicationModuleDetectionStrategy.java 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 4f742128..afd97bd0 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 @@ -109,9 +109,7 @@ public class ApplicationModule implements Comparable { this.exclusions = exclusions; this.classes = basePackage.getClasses(exclusions); this.information = ApplicationModuleInformation.of(basePackage); - this.namedInterfaces = isOpen() - ? NamedInterfaces.forOpen(basePackage) - : NamedInterfaces.discoverNamedInterfaces(basePackage); + this.namedInterfaces = source.getNamedInterfaces(information); this.springBeans = SingletonSupplier.of(() -> filterSpringBeans(classes)); this.aggregateRoots = SingletonSupplier.of(() -> findAggregateRoots(classes)); diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategy.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategy.java index 067c0afd..890d2f21 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategy.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategy.java @@ -29,13 +29,30 @@ public interface ApplicationModuleDetectionStrategy { /** - * Given the {@link JavaPackage} that Moduliths was initialized with, return the base packages for all modules in the - * system. + * Given the {@link JavaPackage} that Spring Modulith was initialized with, return the base packages that are supposed + * to be considered base packages for {@link ApplicationModule}s. + * + * @param rootPackage will never be {@literal null}. + * @return must not be {@literal null}. + */ + Stream getModuleBasePackages(JavaPackage rootPackage); + + /** + * Optionally customize the detection of {@link NamedInterfaces} for a module with the given base package and the + * pre-obtained {@link ApplicationModuleInformation}. Defaults to + * {@link NamedInterfaces#of(JavaPackage, ApplicationModuleInformation)}. {@link NamedInterfaces} exposes a + * {@link NamedInterfaces.Builder} API to define the selection of packages to be considered named interfaces. * * @param basePackage will never be {@literal null}. + * @param information will never be {@literal null}. * @return must not be {@literal null}. + * @see NamedInterfaces#of(JavaPackage, ApplicationModuleInformation) + * @see NamedInterfaces#builder(JavaPackage) + * @since 1.4 */ - Stream getModuleBasePackages(JavaPackage basePackage); + default NamedInterfaces detectNamedInterfaces(JavaPackage basePackage, ApplicationModuleInformation information) { + return NamedInterfaces.of(basePackage, information); + } /** * A {@link ApplicationModuleDetectionStrategy} that considers all direct sub-packages of the Moduliths base package 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 630a904f..5de7c927 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 @@ -45,19 +45,25 @@ public class ApplicationModuleSource { private final JavaPackage moduleBasePackage; private final ApplicationModuleIdentifier identifier; + private final Function namedInterfacesFactory; /** * 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. + * @param namedInterfacesFactory must not be {@literal null}. */ - private ApplicationModuleSource(JavaPackage moduleBasePackage, ApplicationModuleIdentifier identifier) { + private ApplicationModuleSource(JavaPackage moduleBasePackage, ApplicationModuleIdentifier identifier, + Function namedInterfacesFactory) { Assert.notNull(moduleBasePackage, "JavaPackage must not be null!"); + Assert.notNull(identifier, "ApplicationModuleIdentifier must not be null!"); + Assert.notNull(namedInterfacesFactory, "NamedInterfaces factory must not be null!"); this.moduleBasePackage = moduleBasePackage; this.identifier = identifier; + this.namedInterfacesFactory = namedInterfacesFactory; } /** @@ -83,7 +89,7 @@ public static Stream from(JavaPackage rootPackage, .orElseGet(() -> ApplicationModuleIdentifier.of( fullyQualifiedModuleNames ? it.getName() : rootPackage.getTrailingName(it))); - return new ApplicationModuleSource(it, id); + return new ApplicationModuleSource(it, id, (info) -> strategy.detectNamedInterfaces(it, info)); }); } @@ -95,7 +101,8 @@ public static Stream from(JavaPackage rootPackage, * @return will never be {@literal null}. */ static ApplicationModuleSource from(JavaPackage pkg, String identifier) { - return new ApplicationModuleSource(pkg, ApplicationModuleIdentifier.of(identifier)); + return new ApplicationModuleSource(pkg, ApplicationModuleIdentifier.of(identifier), + (info) -> NamedInterfaces.of(pkg, info)); } /** @@ -116,6 +123,20 @@ public ApplicationModuleIdentifier getIdentifier() { return identifier; } + /** + * Returns all {@link NamedInterfaces} for the given {@link ApplicationModuleInformation}. + * + * @param information must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + */ + public NamedInterfaces getNamedInterfaces(ApplicationModuleInformation information) { + + Assert.notNull(information, "ApplicationModuleInformation must not be null!"); + + return namedInterfacesFactory.apply(information); + } + /* * (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java index 0f91ff2c..4ccbf4ca 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/JavaPackage.java @@ -28,6 +28,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.BiPredicate; import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -197,6 +198,23 @@ public Stream getSubPackagesAnnotatedWith(Class of(classes, it)); } + /** + * Returns all sub-packages that match the given {@link BiPredicate} for the canidate package and its trailing name + * relative to the current one. + * + * @param filter must not be {@literal null}. + * @return will never be {@literal null}. + * @see #getTrailingName(JavaPackage) + * @since 1.4 + */ + public Stream getSubPackagesMatching(BiPredicate filter) { + + Assert.notNull(filter, "Filter must not be null!"); + + return getSubPackages().stream() + .filter(it -> filter.test(it, this.getTrailingName(it))); + } + /** * Returns all {@link Classes} that match the given {@link DescribedPredicate}. * diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterface.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterface.java index cb84f638..3a5cad4d 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterface.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterface.java @@ -69,8 +69,9 @@ private NamedInterface(String name, Classes classes) { * * @param javaPackage must not be {@literal null}. * @return will never be {@literal null}. + * @since 1.4 (previously package protected) */ - static List of(JavaPackage javaPackage) { + public static List of(JavaPackage javaPackage) { var basePackage = javaPackage.toSingle(); var names = basePackage.findAnnotation(org.springframework.modulith.NamedInterface.class) // @@ -91,18 +92,44 @@ static List of(JavaPackage javaPackage) { * @param name must not be {@literal null} or empty. * @param classes must not be {@literal null}. * @return will never be {@literal null}. + * @since 1.4 (previously package protected) */ - static NamedInterface of(String name, Classes classes) { + public static NamedInterface of(String name, Classes classes) { return new NamedInterface(name, classes); } + /** + * Returns the unnamed interface for the given package, excluding any sub-packages by convention. + * + * @param javaPackage must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + */ + public static NamedInterface unnamed(JavaPackage javaPackage) { + return unnamed(javaPackage, true); + } + + /** + * Returns an unnamed interface for the given package applying open module semantics, i.e. including all sub-packages + * of the given one. + * + * @param javaPackage must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + */ + public static NamedInterface open(JavaPackage javaPackage) { + return unnamed(javaPackage, false); + } + /** * Creates an unnamed {@link NamedInterface} for the given {@link JavaPackage}. * * @param javaPackage must not be {@literal null}. * @return will never be {@literal null}. */ - static NamedInterface unnamed(JavaPackage javaPackage, boolean flatten) { + private static NamedInterface unnamed(JavaPackage javaPackage, boolean flatten) { + + Assert.notNull(javaPackage, "Java package must not be null!"); var basePackageClasses = (flatten ? javaPackage.toSingle() : javaPackage).getExposedClasses(); diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java index 5c10be27..222363ad 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/NamedInterfaces.java @@ -15,12 +15,16 @@ */ package org.springframework.modulith.core; +import static java.util.stream.Collectors.*; + import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Optional; +import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -55,19 +59,43 @@ private NamedInterfaces(List namedInterfaces) { .toList(); } + /** + * @param basePackage must not be {@literal null}. + * @param information must not be {@literal null}. + * @return + * @since 1.4 + */ + public static NamedInterfaces of(JavaPackage basePackage, ApplicationModuleInformation information) { + + return information.isOpen() + ? NamedInterfaces.forOpen(basePackage) + : NamedInterfaces.discoverNamedInterfaces(basePackage); + } + /** * Discovers all {@link NamedInterfaces} declared for the given {@link JavaPackage}. * * @param basePackage must not be {@literal null}. * @return will never be {@literal null}. + * @since 1.4 (previously package protected) */ - static NamedInterfaces discoverNamedInterfaces(JavaPackage basePackage) { + public static NamedInterfaces discoverNamedInterfaces(JavaPackage basePackage) { - return NamedInterfaces.of(NamedInterface.unnamed(basePackage, true)) + return NamedInterfaces.of(NamedInterface.unnamed(basePackage)) .and(ofAnnotatedPackages(basePackage)) .and(ofAnnotatedTypes(basePackage.getClasses())); } + /** + * Creates a new {@link Builder} to eventually create {@link NamedInterfaces} for the given base package. + * + * @param basePackage must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static Builder builder(JavaPackage basePackage) { + return new Builder(basePackage); + } + /** * Creates a new {@link NamedInterfaces} for the given {@link NamedInterface}s. * @@ -100,11 +128,11 @@ static NamedInterfaces ofAnnotatedPackages(JavaPackage basePackage) { * * @param basePackage must not be {@literal null}. * @return will never be {@literal null}. - * @since 1.2 + * @since 1.2, public since 1.4 */ - static NamedInterfaces forOpen(JavaPackage basePackage) { + public static NamedInterfaces forOpen(JavaPackage basePackage) { - return NamedInterfaces.of(NamedInterface.unnamed(basePackage, false)) + return NamedInterfaces.of(NamedInterface.open(basePackage)) .and(ofAnnotatedPackages(basePackage)) .and(ofAnnotatedTypes(basePackage.getClasses())); } @@ -195,8 +223,9 @@ public Iterator iterator() { * * @param others must not be {@literal null}. * @return will never be {@literal null}. + * @since 1.4 (previously package protected) */ - NamedInterfaces and(Iterable others) { + public NamedInterfaces and(Iterable others) { Assert.notNull(others, "Other NamedInterfaces must not be null!"); @@ -265,4 +294,172 @@ private static List ofAnnotatedTypes(Classes classes) { .map(entry -> NamedInterface.of(entry.getKey(), Classes.of(entry.getValue()))) // .toList(); } + + /** + * A builder API to manually construct {@link NamedInterfaces} instances. Allows selecting packages to create + * {@link NamedInterface} instances for based on excluding and including predicates, name matches etc. Will always + * include the unnamed named interface as it's required for a valid application module. + * + * @author Oliver Drotbohm + * @since 1.4 + */ + public static class Builder { + + private final JavaPackage basePackage; + private final boolean recursive; + private final Predicate inclusions, exclusions; + + /** + * Creates a new {@link Builder} for the given {@link JavaPackage}. + * + * @param basePackage must not be {@literal null}. + */ + private Builder(JavaPackage basePackage) { + this(basePackage, false, __ -> false, __ -> false); + } + + private Builder(JavaPackage basePackage, boolean recursive, + Predicate inclusions, Predicate exclusions) { + + Assert.notNull(basePackage, "Base package must not be null!"); + Assert.notNull(inclusions, "Inclusions must not be null!"); + Assert.notNull(exclusions, "Exclusions must not be null!"); + + this.basePackage = basePackage; + this.recursive = recursive; + this.inclusions = inclusions; + this.exclusions = exclusions; + } + + /** + * Configures the builder to not only consider directly nested packages but also ones nested in those. + * + * @return will never be {@literal null}. + */ + public Builder recursive() { + return new Builder(basePackage, true, inclusions, exclusions); + } + + /** + * Adds all packages with the trailing name relative to the base package matching the given names or expressions, + * unless set up to be excluded (see {@link #excluding(Predicate)} and overloads).. For a base package + * {@code com.acme}, the trailing name of package {@code com.acme.foo} would be {@code foo}. For + * {@code com.acme.foo.bar} it would be {@code foo.bar}. + *

+ * Expressions can use wildcards, such as {@literal *} (for multi-character matches) and {@literal ?} (for + * single-character ones). As soon as an expression contains a dot ({@literal .}), the expression is applied to the + * entire trailing name. Expressions with a dot are applied to all name segments individually. In other words, an + * expression {@code foo} would match both the trailing names {@code foo}, {@code foo.bar}, and {@code bar.foo}. + * + * @param names must not be {@literal null}. + * @return will never be {@literal null}. + * @see #excluding(String...) + */ + public Builder matching(String... names) { + return matching(List.of(names)); + } + + /** + * Adds all packages with the trailing name relative to the base package matching the given names or expressions, + * unless set up to be excluded (see {@link #excluding(Predicate)} and overloads). For a base package + * {@code com.acme}, the trailing name of package {@code com.acme.foo} would be {@code foo}. For + * {@code com.acme.foo.bar} it would be {@code foo.bar}. + *

+ * Expressions can use wildcards, such as {@literal *} (for multi-character matches) and {@literal ?} (for + * single-character ones). As soon as an expression contains a dot ({@literal .}), the expression is applied to the + * entire trailing name. Expressions with a dot are applied to all name segments individually. In other words, an + * expression {@code foo} would match both the trailing names {@code foo}, {@code foo.bar}, and {@code bar.foo}. + * + * @param names must not be {@literal null}. + * @return will never be {@literal null}. + * @see #excluding(Collection) + */ + public Builder matching(Collection names) { + return including(matchesTrailingName(names)); + } + + /** + * Adds all packages matching the given predicate as named interface. + * + * @param inclusions must not be {@literal null}. + * @return will never be {@literal null}. + * @see #excluding(Predicate) + */ + public Builder including(Predicate inclusions) { + + Assert.notNull(inclusions, "Inclusions must not be null!"); + + return new Builder(basePackage, recursive, inclusions, exclusions); + } + + /** + * Excludes the packages with the given name expressions from being considered as named interface. See + * {@link #matching(String...)} for details on matching expressions. + * + * @param expressions must not be {@literal null}. + * @return will never be {@literal null}. + * @see #matching(String...) + */ + public Builder excluding(String... expressions) { + + Assert.notNull(expressions, "Expressions must not be null!"); + + return excluding(List.of(expressions)); + } + + /** + * Excludes the packages with the given name expressions from being considered as named interface. See + * {@link #matching(Collection)} for details on matching expressions. + * + * @param expressions must not be {@literal null}. + * @return will never be {@literal null}. + * @see #matching(Collection) + */ + public Builder excluding(Collection expressions) { + return excluding(Predicate.not(matchesTrailingName(expressions))); + } + + /** + * Excludes the packages matching the given {@link Predicate} from being considered as named interface. + * + * @param exclusions must not be {@literal null}. + * @return will never be {@literal null}. + * @see #including(Predicate) + */ + public Builder excluding(Predicate exclusions) { + return new Builder(basePackage, recursive, inclusions, exclusions); + } + + /** + * Creates a {@link NamedInterfaces} instance according to the builder setup. Will always include the + * unnamed interface established by the {@link JavaPackage} the {@link Builder} was set up for originally, as it's a + * required abstraction for every application module. + * + * @return will never be {@literal null}. + */ + public NamedInterfaces build() { + + var packages = recursive + ? basePackage.getSubPackages().stream() + : basePackage.getDirectSubPackages().stream(); + + var result = packages + .filter(exclusions) + .filter(inclusions) + .map(it -> NamedInterface.of(basePackage.getTrailingName(it), it.getClasses())) + .collect(collectingAndThen(toUnmodifiableList(), NamedInterfaces::new)); + + return result.and(List.of(NamedInterface.unnamed(basePackage))); + } + + private Predicate matchesTrailingName(Collection names) { + + return it -> { + + var trailingName = new PackageName(basePackage.getTrailingName(it)); + + return names.stream().anyMatch(trailingName::nameContainsOrMatches); + }; + } + } } diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java index f12ecb0c..3a4e2050 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/PackageName.java @@ -15,6 +15,8 @@ */ package org.springframework.modulith.core; +import java.util.stream.Stream; + import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -162,6 +164,27 @@ boolean isEmpty() { return length() == 0; } + /** + * Returns whether the package name contains a segment with the given candidate or matches the given expression + * entirely. The latter is tested for if the expression contains a dot, indicating a multi-package match is requested. + * Expressions generally support single character ({@literal ?}) and multi-character ({@literal *}) wildcards. + * + * @param candidate must not be {@literal null} or empty. + * @since 1.4 + */ + boolean nameContainsOrMatches(String candidate) { + + Assert.hasText(candidate, "Expression must not be null or empty!"); + + var regex = candidate.replace(".", "\\.") + .replace("*", ".*") + .replace("?", "."); + + return candidate.contains(".") + ? name.matches(regex) + : Stream.of(segments).anyMatch(it -> it.matches(regex)); + } + /* * (non-Javadoc) * @see java.lang.Comparable#compareTo(java.lang.Object) diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/CustomApplicationModuleDetectionStrategy.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/CustomApplicationModuleDetectionStrategy.java new file mode 100644 index 00000000..658bc717 --- /dev/null +++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/CustomApplicationModuleDetectionStrategy.java @@ -0,0 +1,30 @@ +package org.springframework.modulith.core; + +import java.util.List; +import java.util.stream.Stream; + +class CustomApplicationModuleDetectionStrategy implements ApplicationModuleDetectionStrategy { + + private static final List NAMED_INTERFACE_PACKAGE_NAMES = List.of("mapper", "model", "repository", "service", + "web"); + + private static final List INTERNAL_PACKAGE_NAME = List.of("internal"); + + @Override + public Stream getModuleBasePackages(JavaPackage basePackage) { + + var allExclusions = Stream.concat(NAMED_INTERFACE_PACKAGE_NAMES.stream(), INTERNAL_PACKAGE_NAME.stream()); + + // New method to be introduced on JavaPackage + return basePackage.getSubPackagesMatching((pkg, trailingName) -> allExclusions.noneMatch(trailingName::contains)); + } + + @Override + public NamedInterfaces detectNamedInterfaces(JavaPackage basePackage, ApplicationModuleInformation information) { + + return NamedInterfaces.builder(basePackage) + .matching(NAMED_INTERFACE_PACKAGE_NAMES) + .recursive() + .build(); + } +} diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java index 2dec5abe..3c977646 100644 --- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java +++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/NamedInterfacesUnitTests.java @@ -98,6 +98,38 @@ void detectsNamedInterfacesATypeIsContainedIn() { .containsExactlyInAnyOrder("spi", "kpi"); } + @Test + void createsNamedInterfacesFromBuilder() { + + JavaPackage pkg = TestUtils.getPackage(RootType.class); + + var result = NamedInterfaces.builder(pkg) + .excluding("internal") + .matching("nested") + .build(); + + assertThat(result).hasSize(2) + .extracting(NamedInterface::getName) + .containsExactlyInAnyOrder(NamedInterface.UNNAMED_NAME, "nested"); + } + + @Test + void createsNamedInterfacesFromRecursiveBuilder() { + + JavaPackage pkg = TestUtils.getPackage(RootType.class); + + var result = NamedInterfaces.builder(pkg) + .excluding("internal") + .matching("nested", "internal") + .recursive() + .build(); + + assertThat(result).hasSize(6) + .extracting(NamedInterface::getName) + .containsExactlyInAnyOrder(NamedInterface.UNNAMED_NAME, "nested", "nested.a", "nested.b", "nested.b.first", + "nested.b.second"); + } + private static void assertInterfaceContains(NamedInterfaces interfaces, String name, Class... types) { var classNames = Arrays.stream(types).map(Class::getName).toArray(String[]::new); diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java index 71a6cb58..6c9fa3f3 100644 --- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java +++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/PackageNameUnitTests.java @@ -55,4 +55,21 @@ void caculatesNestingCorrectly() { assertThat(comAcme.contains(comAcmeA)).isTrue(); assertThat(comAcmeA.contains(comAcme)).isFalse(); } + + @Test + void findsMatchingSegments() { + + var source = new PackageName("com.acme.foo"); + + assertThat(source.nameContainsOrMatches("acme")).isTrue(); + assertThat(source.nameContainsOrMatches("*me")).isTrue(); + assertThat(source.nameContainsOrMatches("ac*")).isTrue(); + assertThat(source.nameContainsOrMatches("*m.acme.foo")).isTrue(); + assertThat(source.nameContainsOrMatches("*m.acme.?oo")).isTrue(); + assertThat(source.nameContainsOrMatches("*m.ac*")).isTrue(); + assertThat(source.nameContainsOrMatches("*m.*.fo*")).isTrue(); + + assertThat(source.nameContainsOrMatches("cm")).isFalse(); + + } } diff --git a/src/docs/antora/modules/ROOT/pages/fundamentals.adoc b/src/docs/antora/modules/ROOT/pages/fundamentals.adoc index 90412bde..7595fe40 100644 --- a/src/docs/antora/modules/ROOT/pages/fundamentals.adoc +++ b/src/docs/antora/modules/ROOT/pages/fundamentals.adoc @@ -394,6 +394,8 @@ package example.inventory ---- ====== +If you require more generic control about the named interfaces of an application module, check out xref:fundamentals.adoc#customizing-named-interfaces[the customization section]. + [[customizing-modules-arrangement]] == Customizing the Application Modules Arrangement @@ -581,3 +583,61 @@ public class CustomApplicationModuleSourceFactory implements ApplicationModuleSo 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`. +[[customizing-named-interfaces]] +=== Customizing Named Interface detection + +If you would like to programatically describe the named interfaces of an application module, register an `ApplicationModuleDetectionStrategy` as described xref:fundamentals.adoc#customizing-modules[here] and use the `detectNamedInterfaces(JavaPackage, ApplicationModuleInformation)` to implement a custom discovery algorithm. + +.Customizing the named interface detection using 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 + } + + @Override + NamedInterfaces detectNamedInterfaces(JavaPackage basePackage, ApplicationModuleInformation information) { + return NamedInterfaces.builder() + .recursive() + .matching("api") + .build(); + } +} +---- +Kotlin:: ++ +[source, kotlin, role="secondary"] +---- +package example + +class CustomApplicationModuleDetectionStrategy : ApplicationModuleDetectionStrategy { + + override fun getModuleBasePackages(basePackage: JavaPackage): Stream { + // Your module detection goes here + } + + override fun detectNamedInterfaces(basePackage: JavaPackage, information: ApplicationModuleInformation): NamedInterfaces { + return NamedInterfaces.builder() + .recursive() + .matching("api") + .build() + } +} +---- +====== + +In the `detectNamedInterfaces(…)` implementation shown above, we build up a `NamedInterfaces` instance for all packages named `api` underneath the given application module's base package. +The `Builder` API exposes additional methods to select packages as named interfaces or explicitly exclude them from that. +Note, that the builder will always include the unnamed named interface containing all public methods located in the application module's base package as that interface is required for application modules. + +For a more manual setup of a `NamedInterfaces`, be sure to check out its factory methods and the ones exposed by `NamedInterface`. +