diff --git a/spring-modulith-api/src/main/java/org/springframework/modulith/ApplicationModule.java b/spring-modulith-api/src/main/java/org/springframework/modulith/ApplicationModule.java index 157972e2..18a72c90 100644 --- a/spring-modulith-api/src/main/java/org/springframework/modulith/ApplicationModule.java +++ b/spring-modulith-api/src/main/java/org/springframework/modulith/ApplicationModule.java @@ -32,6 +32,13 @@ public @interface ApplicationModule { public static final String OPEN_TOKEN = "¯\\_(ツ)_/¯"; + /** + * The identifier of the module. Must not contain a double colon ({@code ::}). + * + * @return will never be {@literal null}. + */ + String id() default ""; + /** * The human readable name of the module to be used for display and documentation purposes. * 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 fd891f45..b7ffa212 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 @@ -157,7 +157,7 @@ public class ApplicationModule implements Comparable { * @since 1.3 */ public ApplicationModuleIdentifier getIdentifier() { - return ApplicationModuleIdentifier.of(source.getModuleName()); + return source.getIdentifier(); } /** 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 4c315f39..1ad1eeb8 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,10 +15,16 @@ */ package org.springframework.modulith.core; +import java.lang.annotation.Annotation; import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; import java.util.stream.Stream; +import org.springframework.modulith.ApplicationModule; +import org.springframework.modulith.core.Types.JMoleculesTypes; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * The source of an {@link ApplicationModule}. Essentially a {@link JavaPackage} and associated naming strategy for the @@ -32,8 +38,13 @@ import org.springframework.util.Assert; */ public class ApplicationModuleSource { + private static final ApplicationModuleSourceMetadata ANNOTATION_IDENTIFIER_SOURCE = ApplicationModuleSourceMetadata + .delegating( + JMoleculesTypes.getIdentifierSource(), + ApplicationModuleSourceMetadata.forAnnotation(ApplicationModule.class, ApplicationModule::id)); + private final JavaPackage moduleBasePackage; - private final String moduleName; + private final ApplicationModuleIdentifier identifier; /** * Creates a new {@link ApplicationModuleSource} for the given module base package and module name. @@ -41,50 +52,55 @@ public class ApplicationModuleSource { * @param moduleBasePackage must not be {@literal null}. * @param moduleName must not be {@literal null} or empty. */ - private ApplicationModuleSource(JavaPackage moduleBasePackage, String moduleName) { + private ApplicationModuleSource(JavaPackage moduleBasePackage, ApplicationModuleIdentifier identifier) { 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; + this.identifier = identifier; } /** * Returns a {@link Stream} of {@link ApplicationModuleSource}s by applying the given * {@link ApplicationModuleDetectionStrategy} to the given base package. * - * @param pkg must not be {@literal null}. + * @param rootPackage must not be {@literal null}. * @param strategy must not be {@literal null}. * @param fullyQualifiedModuleNames whether to use fully qualified module names. * @return will never be {@literal null}. */ - public static Stream from(JavaPackage pkg, ApplicationModuleDetectionStrategy strategy, - boolean fullyQualifiedModuleNames) { + public static Stream from(JavaPackage rootPackage, + ApplicationModuleDetectionStrategy strategy, boolean fullyQualifiedModuleNames) { - Assert.notNull(pkg, "Base package must not be null!"); + Assert.notNull(rootPackage, "Root package must not be null!"); Assert.notNull(strategy, "ApplicationModuleDetectionStrategy must not be null!"); - return strategy.getModuleBasePackages(pkg) - .flatMap(it -> it.andSubPackagesAnnotatedWith(org.springframework.modulith.ApplicationModule.class)) - .map(it -> new ApplicationModuleSource(it, fullyQualifiedModuleNames ? it.getName() : pkg.getTrailingName(it))); + return strategy.getModuleBasePackages(rootPackage) + .flatMap(ANNOTATION_IDENTIFIER_SOURCE::withNestedPackages) + .map(it -> { + + var id = ANNOTATION_IDENTIFIER_SOURCE.lookupIdentifier(it) + .orElseGet(() -> ApplicationModuleIdentifier.of( + fullyQualifiedModuleNames ? it.getName() : rootPackage.getTrailingName(it))); + + return new ApplicationModuleSource(it, id); + }); } /** * Creates a new {@link ApplicationModuleSource} for the given {@link JavaPackage} and name. * * @param pkg must not be {@literal null}. - * @param name must not be {@literal null} or empty. + * @param identifier must not be {@literal null} or empty. * @return will never be {@literal null}. */ - public static ApplicationModuleSource from(JavaPackage pkg, String name) { - - Assert.hasText(name, "Name must not be null or empty!"); - - return new ApplicationModuleSource(pkg, name); + static ApplicationModuleSource from(JavaPackage pkg, String identifier) { + return new ApplicationModuleSource(pkg, ApplicationModuleIdentifier.of(identifier)); } /** + * Returns the base package for the module. + * * @return will never be {@literal null}. */ public JavaPackage getModuleBasePackage() { @@ -92,10 +108,12 @@ public class ApplicationModuleSource { } /** - * @return will never be {@literal null} or empty. + * Returns the {@link ApplicationModuleIdentifier} to be used for the module. + * + * @return will never be {@literal null}. */ - public String getModuleName() { - return moduleName; + public ApplicationModuleIdentifier getIdentifier() { + return identifier; } /* @@ -113,7 +131,7 @@ public class ApplicationModuleSource { return false; } - return Objects.equals(this.moduleName, that.moduleName) + return Objects.equals(this.identifier, that.identifier) && Objects.equals(this.moduleBasePackage, that.moduleBasePackage); } @@ -123,6 +141,105 @@ public class ApplicationModuleSource { */ @Override public int hashCode() { - return Objects.hash(moduleName, moduleBasePackage); + return Objects.hash(identifier, moduleBasePackage); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return "ApplicationModuleSource(" + identifier + ", " + moduleBasePackage.getName() + ")"; + } + + /** + * An intermediate abstraction to detect both the {@link ApplicationModuleIdentifier} and potentially nested module + * declarations for the {@link JavaPackage}s returned from the first pass of module detection. + * + * @author Oliver Drotbohm + * @see ApplicationModuleDetectionStrategy + */ + interface ApplicationModuleSourceMetadata { + + /** + * Returns an optional {@link ApplicationModuleIdentifier} obtained by the annotation on the given package. + * + * @param pkg must not be {@literal null}. + * @return will never be {@literal null}. + */ + Optional lookupIdentifier(JavaPackage pkg); + + /** + * Return a {@link Stream} of {@link JavaPackage}s that are + * + * @param pkg must not be {@literal null}. + * @return will never be {@literal null}. + */ + Stream withNestedPackages(JavaPackage pkg); + + /** + * Creates a new {@link ApplicationModuleSourceFactory} detecting the {@link ApplicationModuleIdentifier} based on a + * particular annotation's attribute. It also detects nested {@link JavaPackage}s annotated with the given + * annotation as nested module base packages. + * + * @param an annotation type + * @param annotation must not be {@literal null}. + * @param extractor must not be {@literal null}. + * @return will never be {@literal null}. + */ + static ApplicationModuleSourceMetadata forAnnotation(Class annotation, + Function extractor) { + + Assert.notNull(annotation, "Annotation type must not be null!"); + Assert.notNull(extractor, "Attribute extractor must not be null!"); + + return new ApplicationModuleSourceMetadata() { + + @Override + public Optional lookupIdentifier(JavaPackage pkg) { + + return pkg.getAnnotation(annotation) + .map(extractor) + .filter(StringUtils::hasText) + .map(ApplicationModuleIdentifier::of); + } + + @Override + public Stream withNestedPackages(JavaPackage pkg) { + return pkg.getSubPackagesAnnotatedWith(annotation); + } + }; + } + + /** + * Returns an {@link ApplicationModuleSourceFactory} delegating to the given ones, chosing the first identifier + * found and assembling nested packages of all delegate {@link ApplicationModuleSourceFactory} instances. + * + * @param delegates must not be {@literal null}. + * @return will never be {@literal null}. + */ + private static ApplicationModuleSourceMetadata delegating(ApplicationModuleSourceMetadata... delegates) { + + return new ApplicationModuleSourceMetadata() { + + @Override + public Stream withNestedPackages(JavaPackage pkg) { + + return Stream.concat(Stream.of(pkg), Stream.of(delegates) + .filter(Objects::nonNull) + .flatMap(it -> it.withNestedPackages(pkg))); + } + + @Override + public Optional lookupIdentifier(JavaPackage pkg) { + + return Stream.of(delegates) + .filter(Objects::nonNull) + .flatMap(it -> it.lookupIdentifier(pkg).stream()) + .findFirst(); + } + }; + } } } diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/Types.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/Types.java index f5bd6586..ac094a89 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/Types.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/Types.java @@ -25,8 +25,10 @@ import java.util.function.Predicate; import org.jmolecules.archunit.JMoleculesArchitectureRules; import org.jmolecules.archunit.JMoleculesDddRules; +import org.jmolecules.ddd.annotation.Module; import org.springframework.lang.Nullable; import org.springframework.modulith.PackageInfo; +import org.springframework.modulith.core.ApplicationModuleSource.ApplicationModuleSourceMetadata; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -87,16 +89,18 @@ class Types { } @Nullable - @SuppressWarnings("unchecked") public static Class getModuleAnnotationTypeIfPresent() { + return isModulePresent() ? Module.class : null; + } - try { - return isModulePresent() - ? (Class) ClassUtils.forName(MODULE, JMoleculesTypes.class.getClassLoader()) - : null; - } catch (Exception o_O) { - return null; - } + /** + * Returns an {@link ApplicationModuleSourceMetadata} for the {@link Module} annotation if present. + * + * @return will never be {@literal null}. + */ + @Nullable + public static ApplicationModuleSourceMetadata getIdentifierSource() { + return isModulePresent() ? ApplicationModuleSourceMetadata.forAnnotation(Module.class, Module::id) : null; } /** diff --git a/spring-modulith-core/src/test/java/example/Example.java b/spring-modulith-core/src/test/java/example/Example.java new file mode 100644 index 00000000..174afd01 --- /dev/null +++ b/spring-modulith-core/src/test/java/example/Example.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 example; + +/** + * + * @author Oliver Drotbohm + */ +public class Example { + +} diff --git a/spring-modulith-core/src/test/java/example/jmolecules/package-info.java b/spring-modulith-core/src/test/java/example/jmolecules/package-info.java index 7e327123..c9bc8553 100644 --- a/spring-modulith-core/src/test/java/example/jmolecules/package-info.java +++ b/spring-modulith-core/src/test/java/example/jmolecules/package-info.java @@ -1,2 +1,2 @@ -@org.jmolecules.ddd.annotation.Module +@org.jmolecules.ddd.annotation.Module(id = "customId") package example.jmolecules; diff --git a/spring-modulith-core/src/test/java/example/ni/nested/b/second/package-info.java b/spring-modulith-core/src/test/java/example/ni/nested/b/second/package-info.java index dcf898a7..a7d0436d 100644 --- a/spring-modulith-core/src/test/java/example/ni/nested/b/second/package-info.java +++ b/spring-modulith-core/src/test/java/example/ni/nested/b/second/package-info.java @@ -1,2 +1,2 @@ -@org.springframework.modulith.ApplicationModule +@org.springframework.modulith.ApplicationModule(id = "secondCustomized") package example.ni.nested.b.second; diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModuleSourceUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModuleSourceUnitTests.java new file mode 100644 index 00000000..a9e5b836 --- /dev/null +++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModuleSourceUnitTests.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.modulith.core; + +import static org.assertj.core.api.Assertions.*; + +import example.Example; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ApplicationModuleSource}. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +class ApplicationModuleSourceUnitTests { + + @Test // GH-846 + void detectsSources() { + + var rootPackage = TestUtils.getPackage(Example.class); + var detectionStrategy = ApplicationModuleDetectionStrategy.directSubPackage(); + + var sources = ApplicationModuleSource.from(rootPackage, detectionStrategy, false); + + assertThat(sources) + .extracting(ApplicationModuleSource::getIdentifier) + .extracting(ApplicationModuleIdentifier::toString) + .contains("ninvalid", "customId", "invalid", "ni", "ni.nested.b.first", "secondCustomized", "ni.nested"); + } +} diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModulesUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModulesUnitTests.java index 96a6d681..3f2b2762 100644 --- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModulesUnitTests.java +++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/ApplicationModulesUnitTests.java @@ -36,11 +36,11 @@ class ApplicationModulesUnitTests { .extracting(Object::toString) .containsExactlyInAnyOrder( "invalid", - "jmolecules", + "customId", "ni", "ni.nested", "ni.nested.b.first", - "ni.nested.b.second", + "secondCustomized", "springbean"); } @@ -63,7 +63,7 @@ class ApplicationModulesUnitTests { assertThat(ni.getNestedModules(modules)) .extracting(ApplicationModule::getIdentifier) .extracting(Object::toString) - .containsExactlyInAnyOrder("ni.nested", "ni.nested.b.first", "ni.nested.b.second"); + .containsExactlyInAnyOrder("ni.nested", "ni.nested.b.first", "secondCustomized"); } @Test // GH 578