GH-846 - Fix module identifier lookup for jMolecules @Module(id = …).

We now properly translate jMolecules' @Module declaration configuring the id attribute explicitly into the identifier used for an application module.

Introduce @ApplicationModule(id = …) for Spring Modulith-native identifier customization.
This commit is contained in:
Oliver Drotbohm
2024-10-13 16:33:37 +02:00
parent de4668319c
commit 413e2f50b6
9 changed files with 233 additions and 36 deletions

View File

@@ -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.
*

View File

@@ -157,7 +157,7 @@ public class ApplicationModule implements Comparable<ApplicationModule> {
* @since 1.3
*/
public ApplicationModuleIdentifier getIdentifier() {
return ApplicationModuleIdentifier.of(source.getModuleName());
return source.getIdentifier();
}
/**

View File

@@ -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<ApplicationModuleSource> from(JavaPackage pkg, ApplicationModuleDetectionStrategy strategy,
boolean fullyQualifiedModuleNames) {
public static Stream<ApplicationModuleSource> 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<ApplicationModuleIdentifier> 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<JavaPackage> 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 <T> an annotation type
* @param annotation must not be {@literal null}.
* @param extractor must not be {@literal null}.
* @return will never be {@literal null}.
*/
static <T extends Annotation> ApplicationModuleSourceMetadata forAnnotation(Class<T> annotation,
Function<T, String> 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<ApplicationModuleIdentifier> lookupIdentifier(JavaPackage pkg) {
return pkg.getAnnotation(annotation)
.map(extractor)
.filter(StringUtils::hasText)
.map(ApplicationModuleIdentifier::of);
}
@Override
public Stream<JavaPackage> 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<JavaPackage> withNestedPackages(JavaPackage pkg) {
return Stream.concat(Stream.of(pkg), Stream.of(delegates)
.filter(Objects::nonNull)
.flatMap(it -> it.withNestedPackages(pkg)));
}
@Override
public Optional<ApplicationModuleIdentifier> lookupIdentifier(JavaPackage pkg) {
return Stream.of(delegates)
.filter(Objects::nonNull)
.flatMap(it -> it.lookupIdentifier(pkg).stream())
.findFirst();
}
};
}
}
}

View File

@@ -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<? extends Annotation> getModuleAnnotationTypeIfPresent() {
return isModulePresent() ? Module.class : null;
}
try {
return isModulePresent()
? (Class<? extends Annotation>) 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;
}
/**

View File

@@ -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 {
}

View File

@@ -1,2 +1,2 @@
@org.jmolecules.ddd.annotation.Module
@org.jmolecules.ddd.annotation.Module(id = "customId")
package example.jmolecules;

View File

@@ -1,2 +1,2 @@
@org.springframework.modulith.ApplicationModule
@org.springframework.modulith.ApplicationModule(id = "secondCustomized")
package example.ni.nested.b.second;

View File

@@ -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");
}
}

View File

@@ -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