diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/FormatableType.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/FormatableType.java index 765ca6be..3693c0f5 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/FormatableType.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/FormatableType.java @@ -19,6 +19,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -79,6 +80,22 @@ public class FormatableType { return CACHE.computeIfAbsent(type.getName(), FormatableType::new); } + /** + * Formats the given {@link JavaClass}es by rendering a comma-separated list with the abbreviated class names. + * + * @param types must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static String format(Iterable types) { + + Assert.notNull(types, "Types must not be null!"); + + return StreamSupport.stream(types.spliterator(), false) + .map(FormatableType::of) + .map(FormatableType::getAbbreviatedFullName) + .collect(Collectors.joining(", ")); + } + /** * Creates a new {@link FormatableType} for the given fully-qualified type name. * 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 b6ee45a7..3afcace2 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 @@ -15,13 +15,19 @@ */ package org.springframework.modulith.core; +import static com.tngtech.archunit.base.DescribedPredicate.*; +import static org.springframework.modulith.core.Types.*; + import java.util.Iterator; import java.util.List; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.util.Assert; +import com.tngtech.archunit.base.DescribedPredicate; import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.core.domain.JavaClass.Predicates; +import com.tngtech.archunit.core.domain.properties.CanBeAnnotated; /** * A named interface into an {@link ApplicationModule}. This can either be a package, explicitly annotated with @@ -35,6 +41,8 @@ import com.tngtech.archunit.core.domain.JavaClass.Predicates; public class NamedInterface implements Iterable { static final String UNNAMED_NAME = "<>"; + private static final DescribedPredicate ANNOTATED_NAMED_INTERFACE = // + isAnnotatedWith(org.springframework.modulith.NamedInterface.class); private final String name; private final Classes classes; @@ -90,7 +98,21 @@ public class NamedInterface implements Iterable { * @return will never be {@literal null}. */ static NamedInterface unnamed(JavaPackage javaPackage) { - return new NamedInterface(UNNAMED_NAME, javaPackage.toSingle().getExposedClasses()); + + var basePackageClasses = javaPackage.toSingle().getExposedClasses(); + + // Types that declare the annotation but no explicit name + var withDefaultedNamedInterface = basePackageClasses.stream() + .filter(ANNOTATED_NAMED_INTERFACE) + .filter(NamedInterface::withDefaultedNamedInterface) + .toList(); + + // Illegal in the base package + Assert.state(withDefaultedNamedInterface.isEmpty(), + () -> "Cannot use named interface defaulting for type(s) %s located in base package!" + .formatted(FormatableType.format(withDefaultedNamedInterface))); + + return new NamedInterface(UNNAMED_NAME, basePackageClasses.that(not(ANNOTATED_NAMED_INTERFACE))); } /** @@ -174,7 +196,9 @@ public class NamedInterface implements Iterable { */ @Override public String toString() { - return "NamedInterface: name=%s, types=%s".formatted(name, classes); + + return "NamedInterface: name=%s, types=[%s]" // + .formatted(name, classes.isEmpty() ? "" : " " + FormatableType.format(classes) + " "); } /** @@ -196,4 +220,19 @@ public class NamedInterface implements Iterable { ? List.of(packageName.substring(packageName.lastIndexOf('.') + 1)) : List.of(declaredNames); } + + /** + * Returns whether the given {@link JavaClass} is annotated with {@link org.springframework.modulith.NamedInterface} + * but does not declare a name explicitly. + * + * @param type must not be {@literal null}. + * @return + */ + private static boolean withDefaultedNamedInterface(JavaClass type) { + + var annotation = AnnotatedElementUtils.getMergedAnnotation(type.reflect(), + org.springframework.modulith.NamedInterface.class); + + return annotation != null && annotation.name().length == 0; + } } 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 c40182e7..e20c36e3 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 @@ -17,6 +17,7 @@ package org.springframework.modulith.core; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Optional; @@ -49,7 +50,9 @@ public class NamedInterfaces implements Iterable { Assert.notNull(namedInterfaces, "Named interfaces must not be null!"); - this.namedInterfaces = namedInterfaces; + this.namedInterfaces = namedInterfaces.stream() + .sorted(Comparator.comparing(NamedInterface::getName)) + .toList(); } /** @@ -60,9 +63,9 @@ public class NamedInterfaces implements Iterable { */ static NamedInterfaces discoverNamedInterfaces(JavaPackage basePackage) { - return NamedInterfaces.ofAnnotatedPackages(basePackage) // - .and(NamedInterfaces.ofAnnotatedTypes(basePackage)) // - .and(NamedInterface.unnamed(basePackage)); + return NamedInterfaces.of(NamedInterface.unnamed(basePackage)) + .and(ofAnnotatedPackages(basePackage)) + .and(ofAnnotatedTypes(basePackage)); } /** @@ -150,14 +153,14 @@ public class NamedInterfaces implements Iterable { * @param others must not be {@literal null}. * @return will never be {@literal null}. */ - NamedInterfaces and(List others) { + NamedInterfaces and(Iterable others) { Assert.notNull(others, "Other NamedInterfaces must not be null!"); var namedInterfaces = new ArrayList(); - var unmergedInterface = this.namedInterfaces; + var unmergedInterfaces = new ArrayList<>(this.namedInterfaces); - if (others.isEmpty()) { + if (!others.iterator().hasNext()) { return this; } @@ -170,23 +173,18 @@ public class NamedInterfaces implements Iterable { // Merge existing with new and add to result existing.ifPresentOrElse(it -> { namedInterfaces.add(it.merge(candidate)); - unmergedInterface.remove(it); + unmergedInterfaces.remove(it); }, () -> namedInterfaces.add(candidate)); } - namedInterfaces.addAll(unmergedInterface); + namedInterfaces.addAll(unmergedInterfaces); return new NamedInterfaces(namedInterfaces); } - private NamedInterfaces and(NamedInterface namedInterface) { - - var result = new ArrayList(namedInterfaces.size() + 1); - result.addAll(namedInterfaces); - result.add(namedInterface); - - return new NamedInterfaces(result); + private static NamedInterfaces of(NamedInterface interfaces) { + return new NamedInterfaces(List.of(interfaces)); } private static List ofAnnotatedTypes(JavaPackage basePackage) { diff --git a/spring-modulith-core/src/test/java/example/ni/AnnotatedNamedInterfaceType.java b/spring-modulith-core/src/test/java/example/ni/AnnotatedNamedInterfaceType.java new file mode 100644 index 00000000..cbaa3017 --- /dev/null +++ b/spring-modulith-core/src/test/java/example/ni/AnnotatedNamedInterfaceType.java @@ -0,0 +1,26 @@ +/* + * Copyright 2023 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.ni; + +import org.springframework.modulith.NamedInterface; + +/** + * @author Oliver Drotbohm + */ +@NamedInterface("api") +public interface AnnotatedNamedInterfaceType { + +} diff --git a/spring-modulith-core/src/test/java/example/ninvalid/InvalidDefaultNamedInterface.java b/spring-modulith-core/src/test/java/example/ninvalid/InvalidDefaultNamedInterface.java new file mode 100644 index 00000000..8a71ae59 --- /dev/null +++ b/spring-modulith-core/src/test/java/example/ninvalid/InvalidDefaultNamedInterface.java @@ -0,0 +1,24 @@ +/* + * Copyright 2023 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.ninvalid; + +import org.springframework.modulith.NamedInterface; + +/** + * @author Oliver Drotbohm + */ +@NamedInterface +public interface InvalidDefaultNamedInterface {} 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 a6b30475..820dac3c 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 @@ -17,11 +17,13 @@ package org.springframework.modulith.core; import static org.assertj.core.api.Assertions.*; +import example.ni.AnnotatedNamedInterfaceType; import example.ni.RootType; import example.ni.api.ApiType; import example.ni.internal.AdditionalSpiType; import example.ni.internal.DefaultedNamedInterfaceType; import example.ni.spi.SpiType; +import example.ninvalid.InvalidDefaultNamedInterface; import java.util.Arrays; @@ -39,21 +41,29 @@ class NamedInterfacesUnitTests { @Test void discoversNamedInterfaces() { - var classes = TestUtils.getClasses(RootType.class); - var javaPackage = JavaPackage.of(classes, RootType.class.getPackageName()); - + var javaPackage = TestUtils.getPackage(RootType.class); var interfaces = NamedInterfaces.discoverNamedInterfaces(javaPackage); assertThat(interfaces).map(NamedInterface::getName) .containsExactlyInAnyOrder(NamedInterface.UNNAMED_NAME, "api", "spi", "kpi", "internal"); assertInterfaceContains(interfaces, NamedInterface.UNNAMED_NAME, RootType.class); - assertInterfaceContains(interfaces, "api", ApiType.class); + assertInterfaceContains(interfaces, "api", ApiType.class, AnnotatedNamedInterfaceType.class); assertInterfaceContains(interfaces, "spi", SpiType.class, AdditionalSpiType.class); assertInterfaceContains(interfaces, "kpi", AdditionalSpiType.class); assertInterfaceContains(interfaces, "internal", DefaultedNamedInterfaceType.class); } + @Test // GH-183 + void rejectsDefaultingNamedInterfaceTypeInBasePackage() { + + var javaPackage = TestUtils.getPackage(InvalidDefaultNamedInterface.class); + + assertThatIllegalStateException().isThrownBy(() -> NamedInterfaces.discoverNamedInterfaces(javaPackage)) + .withMessageContaining("named interface defaulting") + .withMessageContaining(InvalidDefaultNamedInterface.class.getSimpleName()); + } + 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/TestUtils.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/TestUtils.java index f1d813e6..1272ef8b 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 @@ -70,4 +70,8 @@ class TestUtils { .importPackagesOf(packageType) .that(resideInAPackage(packageType.getPackage().getName() + ".."))); } + + public static JavaPackage getPackage(Class packageType) { + return JavaPackage.of(TestUtils.getClasses(packageType), packageType.getPackageName()); + } }