GH-177 - Named interfaces names now default to the local package name.

Both package- and type-level declarations now use the local package name as the named interface's name. This allows to, at the same time, easily declared named interfaces based on packages but also a nice decoupling of the interface definition and the package layout as individual types can be assigned to such interfaces no matter where they are actually declared.
This commit is contained in:
Oliver Drotbohm
2023-03-31 13:34:21 +02:00
parent 6d2fdd6fdc
commit 2454ecbc4b
13 changed files with 266 additions and 155 deletions

View File

@@ -24,8 +24,7 @@ import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
/**
* Annotation to mark a package as named interface of a {@link ApplicationModule} (either implicit or explicitly
* annotated).
* Annotation to mark a package as named interface of a {@link ApplicationModule} or assign a type to a named interface.
*
* @author Oliver Drotbohm
*/
@@ -35,19 +34,21 @@ import org.springframework.core.annotation.AliasFor;
public @interface NamedInterface {
/**
* The name(s) of the named interface. Declaring multiple values here is useful in case named interfaces are defined
* based on types and a particular type is supposed to be part of multiple named interfaces.
* The name(s) of the named interface. If declared on a package, the package's local name will be used as default
* name. Declaring multiple values here is useful in case named interfaces are defined based on types and a particular
* type is supposed to be part of multiple named interfaces.
*
* @return
* @return will never be {@literal null}.
*/
@AliasFor("name")
String[] value() default {};
/**
* The name(s) of the named interface. Declaring multiple values here is useful in case named interfaces are defined
* based on types and a particular type is supposed to be part of multiple named interfaces.
* The name(s) of the named interface. If declared on a package, the package's local name will be used as default
* name. Declaring multiple values here is useful in case named interfaces are defined based on types and a particular
* type is supposed to be part of multiple named interfaces.
*
* @return
* @return will never be {@literal null}.
*/
@AliasFor("value")
String[] name() default {};

View File

@@ -32,7 +32,9 @@ import org.springframework.util.Assert;
import com.tngtech.archunit.base.DescribedIterable;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.domain.properties.CanBeAnnotated;
import com.tngtech.archunit.core.domain.properties.HasModifiers;
import com.tngtech.archunit.thirdparty.com.google.common.base.Supplier;
import com.tngtech.archunit.thirdparty.com.google.common.base.Suppliers;
@@ -121,7 +123,7 @@ public class JavaPackage implements DescribedIterable<JavaClass> {
* @return will never be {@literal null}.
*/
public String getLocalName() {
return name.substring(name.lastIndexOf(".") + 1);
return name.substring(name.lastIndexOf('.') + 1);
}
/**
@@ -143,6 +145,17 @@ public class JavaPackage implements DescribedIterable<JavaClass> {
return packageClasses;
}
/**
* Returns the classes exposed by this package, i.e. only public ones. Also filters the {@code package-info} type.
*
* @return will never be {@literal null}.
*/
public Classes getExposedClasses() {
return packageClasses.that(HasModifiers.Predicates.modifier(JavaModifier.PUBLIC)) //
.that(DescribedPredicate.not(JavaClass.Predicates.simpleName(PACKAGE_INFO_NAME)));
}
/**
* Returns all sub-packages that carry the given annotation type.
*

View File

@@ -15,17 +15,13 @@
*/
package org.springframework.modulith.core;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
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.JavaModifier;
import com.tngtech.archunit.core.domain.properties.HasModifiers;
/**
* A named interface into an {@link ApplicationModule}. This can either be a package, explicitly annotated with
@@ -36,23 +32,24 @@ import com.tngtech.archunit.core.domain.properties.HasModifiers;
* @author Oliver Drotbohm
* @see org.springframework.modulith.ApplicationModule#allowedDependencies()
*/
public abstract class NamedInterface implements Iterable<JavaClass> {
public class NamedInterface implements Iterable<JavaClass> {
private static final String UNNAMED_NAME = "<<UNNAMED>>";
private static final String PACKAGE_INFO_NAME = "package-info";
static final String UNNAMED_NAME = "<<UNNAMED>>";
protected final String name;
private final String name;
private final Classes classes;
/**
* Creates a new {@link NamedInterface} with the given name.
*
* @param name must not be {@literal null} or empty.
*/
protected NamedInterface(String name) {
private NamedInterface(String name, Classes classes) {
Assert.hasText(name, "Name must not be null or empty!");
this.name = name;
this.classes = classes;
}
/**
@@ -61,15 +58,17 @@ public abstract class NamedInterface implements Iterable<JavaClass> {
* @param javaPackage must not be {@literal null}.
* @return will never be {@literal null}.
*/
public static List<NamedInterface> of(JavaPackage javaPackage) {
static List<NamedInterface> of(JavaPackage javaPackage) {
String[] name = javaPackage.getAnnotation(org.springframework.modulith.NamedInterface.class) //
.map(it -> it.name()) //
var names = javaPackage.getAnnotation(org.springframework.modulith.NamedInterface.class) //
.map(it -> getDefaultedNames(it, javaPackage.getName())) //
.orElseThrow(() -> new IllegalArgumentException(
String.format("Couldn't find NamedInterface annotation on package %s!", javaPackage)));
return Arrays.stream(name) //
.<NamedInterface> map(it -> new PackageBasedNamedInterface(it, javaPackage)) //
var classes = javaPackage.toSingle().getExposedClasses();
return names.stream()
.<NamedInterface> map(it -> new NamedInterface(it, classes)) //
.toList();
}
@@ -81,8 +80,8 @@ public abstract class NamedInterface implements Iterable<JavaClass> {
* @param basePackage must not be {@literal null}.
* @return will never be {@literal null}.
*/
public static TypeBasedNamedInterface of(String name, Classes classes, JavaPackage basePackage) {
return new TypeBasedNamedInterface(name, classes, basePackage);
static NamedInterface of(String name, Classes classes) {
return new NamedInterface(name, classes);
}
/**
@@ -92,7 +91,7 @@ public abstract class NamedInterface implements Iterable<JavaClass> {
* @return will never be {@literal null}.
*/
static NamedInterface unnamed(JavaPackage javaPackage) {
return new PackageBasedNamedInterface(UNNAMED_NAME, javaPackage);
return new NamedInterface(UNNAMED_NAME, javaPackage.toSingle().getExposedClasses());
}
/**
@@ -120,7 +119,7 @@ public abstract class NamedInterface implements Iterable<JavaClass> {
Assert.notNull(type, "JavaClass must not be null!");
return getClasses().contains(type);
return classes.contains(type);
}
/**
@@ -132,7 +131,7 @@ public abstract class NamedInterface implements Iterable<JavaClass> {
Assert.notNull(type, "Type must not be null!");
return !getClasses().that(Predicates.equivalentTo(type)).isEmpty();
return !classes.that(Predicates.equivalentTo(type)).isEmpty();
}
/**
@@ -144,7 +143,7 @@ public abstract class NamedInterface implements Iterable<JavaClass> {
Assert.notNull(other, "NamedInterface must not be null!");
return this.name.equals(other.name);
return name.equals(other.name);
}
/*
@@ -153,128 +152,49 @@ public abstract class NamedInterface implements Iterable<JavaClass> {
*/
@Override
public Iterator<JavaClass> iterator() {
return getClasses().iterator();
return classes.iterator();
}
/**
* Returns all {@link Classes} making up this {@link NamedInterface}.
*
* @return will never be {@literal null}.
*/
protected abstract Classes getClasses();
/**
* Merges the current {@link NamedInterface} with the given {@link TypeBasedNamedInterface}.
*
* @param other must not be {@literal null}.
* @return will never be {@literal null}.
*/
public abstract NamedInterface merge(TypeBasedNamedInterface other);
NamedInterface merge(NamedInterface other) {
private static class PackageBasedNamedInterface extends NamedInterface {
Assert.isTrue(this.name.equals(other.name),
() -> "Named interfaces name must be equal to %s but was %s".formatted(name, other.name));
private final Classes classes;
private final JavaPackage javaPackage;
public PackageBasedNamedInterface(String name, JavaPackage pkg) {
super(name);
Assert.notNull(pkg, "Package must not be null!");
Assert.hasText(name, "Package name must not be null or empty!");
this.classes = pkg.toSingle().getClasses() //
.that(HasModifiers.Predicates.modifier(JavaModifier.PUBLIC)) //
.that(DescribedPredicate.not(JavaClass.Predicates.simpleName(PACKAGE_INFO_NAME)));
this.javaPackage = pkg;
}
private PackageBasedNamedInterface(String name, Classes classes, JavaPackage pkg) {
super(name);
this.classes = classes;
this.javaPackage = pkg;
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.model.NamedInterface#getClasses()
*/
@Override
public Classes getClasses() {
return classes;
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.model.NamedInterface#merge(org.springframework.modulith.model.NamedInterface.TypeBasedNamedInterface)
*/
@Override
public NamedInterface merge(TypeBasedNamedInterface other) {
return new PackageBasedNamedInterface(name, classes.and(other.classes), javaPackage);
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.model.NamedInterface#toString()
*/
@Override
public String toString() {
return String.format("%s - Public types residing in %s:\n%s\n", name, javaPackage.getName(),
classes.format(javaPackage.getName()));
}
return new NamedInterface(name, classes.and(other.classes));
}
public static class TypeBasedNamedInterface extends NamedInterface {
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return "NamedInterface: name=%s, types=%s".formatted(name, classes);
}
private final Classes classes;
private final JavaPackage pkg;
/**
* Returns the names declared in the given {@link org.springframework.modulith.NamedInterface} annotation or defaults
* to the local name of the given package if none declared.
*
* @param annotation must not be {@literal null}.
* @param packageName must not be {@literal null}.
* @return will never be {@literal null}.
*/
static List<String> getDefaultedNames(org.springframework.modulith.NamedInterface annotation, String packageName) {
/**
* Creates a new {@link TypeBasedNamedInterface} with the given name, {@link Classes} and {@link JavaPackage}.
*
* @param name must not be {@literal null} or empty.
* @param types must not be {@literal null}.
* @param pkg must not be {@literal null}.
*/
public TypeBasedNamedInterface(String name, Classes types, JavaPackage pkg) {
Assert.notNull(annotation, "NamedInterface must not be null!");
Assert.hasText(packageName, "Package name must not be null or empty!");
super(name);
var declaredNames = annotation.name();
Assert.notNull(types, "Classes must not be null!");
Assert.notNull(pkg, "JavaPackage must not be null!");
this.classes = types;
this.pkg = pkg;
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.model.NamedInterface#getClasses()
*/
@Override
public Classes getClasses() {
return classes;
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.model.NamedInterface#merge(org.springframework.modulith.model.NamedInterface.TypeBasedNamedInterface)
*/
@Override
public NamedInterface merge(TypeBasedNamedInterface other) {
return new TypeBasedNamedInterface(name, classes.and(other.classes), pkg);
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.model.NamedInterface#toString()
*/
@Override
public String toString() {
return String.format("%s - Types underneath base package %s:\n%s\n", name, pkg.getName(),
classes.format(pkg.getName()));
}
return declaredNames.length == 0
? List.of(packageName.substring(packageName.lastIndexOf('.') + 1))
: List.of(declaredNames);
}
}

View File

@@ -24,7 +24,6 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.modulith.core.NamedInterface.TypeBasedNamedInterface;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
@@ -151,7 +150,7 @@ public class NamedInterfaces implements Iterable<NamedInterface> {
* @param others must not be {@literal null}.
* @return will never be {@literal null}.
*/
NamedInterfaces and(List<TypeBasedNamedInterface> others) {
NamedInterfaces and(List<NamedInterface> others) {
Assert.notNull(others, "Other TypeBasedNamedInterfaces must not be null!");
@@ -162,23 +161,18 @@ public class NamedInterfaces implements Iterable<NamedInterface> {
return this;
}
for (TypeBasedNamedInterface candidate : others) {
for (NamedInterface candidate : others) {
var existing = namedInterfaces.stream() //
.filter(it -> it.hasSameNameAs(candidate)) //
var existing = this.namedInterfaces.stream() //
.filter(candidate::hasSameNameAs) //
.findFirst();
// Merge existing with new and add to result
existing.ifPresent(it -> {
existing.ifPresentOrElse(it -> {
namedInterfaces.add(it.merge(candidate));
namedInterfaces.add(it);
unmergedInterface.remove(it);
});
// Simply add candidate
if (!existing.isPresent()) {
namedInterfaces.add(candidate);
}
},
() -> namedInterfaces.add(candidate));
}
namedInterfaces.addAll(unmergedInterface);
@@ -195,7 +189,7 @@ public class NamedInterfaces implements Iterable<NamedInterface> {
return new NamedInterfaces(result);
}
private static List<TypeBasedNamedInterface> ofAnnotatedTypes(JavaPackage basePackage) {
private static List<NamedInterface> ofAnnotatedTypes(JavaPackage basePackage) {
var mappings = new LinkedMultiValueMap<String, JavaClass>();
@@ -210,13 +204,12 @@ public class NamedInterfaces implements Iterable<NamedInterface> {
var annotation = AnnotatedElementUtils.getMergedAnnotation(it.reflect(),
org.springframework.modulith.NamedInterface.class);
for (String name : annotation.name()) {
mappings.add(name, it);
}
NamedInterface.getDefaultedNames(annotation, it.getPackageName())
.forEach(name -> mappings.add(name, it));
});
return mappings.entrySet().stream() //
.map(entry -> NamedInterface.of(entry.getKey(), Classes.of(entry.getValue()), basePackage)) //
.map(entry -> NamedInterface.of(entry.getKey(), Classes.of(entry.getValue()))) //
.toList();
}
}

View File

@@ -0,0 +1,21 @@
/*
* 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;
/**
* @author Oliver Drotbohm
*/
public interface RootType {}

View File

@@ -0,0 +1,21 @@
/*
* 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.api;
/**
* @author Oliver Drotbohm
*/
public interface ApiType {}

View File

@@ -0,0 +1,2 @@
@org.springframework.modulith.NamedInterface
package example.ni.api;

View File

@@ -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.ni.internal;
import org.springframework.modulith.NamedInterface;
/**
* @author Oliver Drotbohm
*/
@NamedInterface({ "spi", "kpi" })
public interface AdditionalSpiType {}

View File

@@ -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.internal;
import org.springframework.modulith.NamedInterface;
/**
* @author Oliver Drotbohm
*/
@NamedInterface
public interface DefaultedNamedInterfaceType {
}

View File

@@ -0,0 +1,21 @@
/*
* 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.spi;
/**
* @author Oliver Drotbohm
*/
public interface SpiType {}

View File

@@ -0,0 +1,2 @@
@org.springframework.modulith.NamedInterface
package example.ni.spi;

View File

@@ -0,0 +1,65 @@
/*
* 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 org.springframework.modulith.core;
import static org.assertj.core.api.Assertions.*;
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 java.util.Arrays;
import org.junit.jupiter.api.Test;
import com.tngtech.archunit.core.domain.JavaClass;
/**
* Unit tests for {@link NamedInterfaces}.
*
* @author Oliver Drotbohm
*/
class NamedInterfacesUnitTests {
@Test
void discoversNamedInterfaces() {
var classes = TestUtils.getClasses(RootType.class);
var javaPackage = JavaPackage.of(classes, RootType.class.getPackageName());
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, "spi", SpiType.class, AdditionalSpiType.class);
assertInterfaceContains(interfaces, "kpi", AdditionalSpiType.class);
assertInterfaceContains(interfaces, "internal", DefaultedNamedInterfaceType.class);
}
private static void assertInterfaceContains(NamedInterfaces interfaces, String name, Class<?>... types) {
var classNames = Arrays.stream(types).map(Class::getName).toArray(String[]::new);
assertThat(interfaces.getByName(name)).hasValueSatisfying(it -> {
assertThat(it).map(JavaClass::getName).containsExactlyInAnyOrder(classNames);
});
}
}

View File

@@ -66,6 +66,8 @@ class TestUtils {
Assert.notNull(packageType, "Package type must not be null!");
return getClasses().that(resideInAPackage(packageType.getPackage().getName()));
return Classes.of(new ClassFileImporter()
.importPackagesOf(packageType)
.that(resideInAPackage(packageType.getPackage().getName() + "..")));
}
}