GH-267 - Explicitly declared empty allowed dependencies now forbids any dependency.

The default for @ApplicationModule(allowedDependencies) is now a single element list with a dedicated token we recognize as "all dependencies allowed". This allows users to declare an empty array explicitly to disallow any outgoing dependencies for an application module. Previously, such a declaration would have allowed any dependency.
This commit is contained in:
Oliver Drotbohm
2023-08-15 19:52:02 +02:00
parent cec759af0c
commit 9568f29613
11 changed files with 221 additions and 38 deletions

View File

@@ -29,6 +29,8 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
public @interface ApplicationModule {
public static final String OPEN_TOKEN = "¯\\_(ツ)_/¯";
/**
* The human readable name of the module to be used for display and documentation purposes.
*
@@ -37,14 +39,17 @@ public @interface ApplicationModule {
String displayName() default "";
/**
* List the names of modules that the module is allowed to depend on. Shared modules defined in {@link Modulith} will
* be allowed, too. Names listed are local ones, unless the application has configured
* {@link Modulithic#useFullyQualifiedModuleNames()} to {@literal true}. Explicit references to
* List the names of modules that the module is allowed to depend on. Shared modules defined in
* {@link Modulith}/{@link Modulithic} will be allowed, too. Names listed are local ones, unless the application has
* configured {@link Modulithic#useFullyQualifiedModuleNames()} to {@literal true}. Explicit references to
* {@link NamedInterface}s need to be separated by a double colon {@code ::}, e.g. {@code module::API} if
* {@code module} is the logical module name and {@code API} is the name of the named interface.
* <p>
* Declaring an empty array will allow no dependencies to other modules. To not restrict the dependencies at all,
* leave the attribute at its default value.
*
* @return will never be {@literal null}.
* @see NamedInterface
*/
String[] allowedDependencies() default {};
String[] allowedDependencies() default { OPEN_TOKEN };
}

View File

@@ -374,20 +374,20 @@ public class ApplicationModule {
}
/**
* Returns all allowed module dependencies, either explicitly declared or defined as shared on the given
* Returns all declared module dependencies, either explicitly declared or defined as shared on the given
* {@link ApplicationModules} instance.
*
* @param modules must not be {@literal null}.
* @return
*/
DeclaredDependencies getAllowedDependencies(ApplicationModules modules) {
DeclaredDependencies getDeclaredDependencies(ApplicationModules modules) {
Assert.notNull(modules, "Modules must not be null!");
var allowedDependencyNames = information.getAllowedDependencies();
var allowedDependencyNames = information.getDeclaredDependencies();
if (allowedDependencyNames.isEmpty()) {
return new DeclaredDependencies(Collections.emptyList());
if (DeclaredDependencies.isOpen(allowedDependencyNames)) {
return DeclaredDependencies.open();
}
var explicitlyDeclaredModules = allowedDependencyNames.stream() //
@@ -398,7 +398,7 @@ public class ApplicationModule {
return Stream.concat(explicitlyDeclaredModules, sharedDependencies) //
.distinct() //
.collect(Collectors.collectingAndThen(Collectors.toList(), DeclaredDependencies::new));
.collect(Collectors.collectingAndThen(Collectors.toList(), DeclaredDependencies::closed));
}
/**
@@ -655,6 +655,19 @@ public class ApplicationModule {
return namedInterface.contains(type);
}
/**
* Returns whether the {@link DeclaredDependency} contains the given {@link Class}.
*
* @param type must not be {@literal null}.
* @return
*/
public boolean contains(Class<?> type) {
Assert.notNull(type, "Type must not be null!");
return namedInterface.contains(type);
}
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
@@ -701,38 +714,55 @@ public class ApplicationModule {
*/
static class DeclaredDependencies {
private static final String OPEN_TOKEN = "¯\\_(ツ)_/¯";
private final List<DeclaredDependency> dependencies;
private final boolean closed;
static boolean isOpen(List<String> declaredDependencies) {
return declaredDependencies.size() == 1 && declaredDependencies.get(0).equals(OPEN_TOKEN);
}
public static DeclaredDependencies open() {
return new DeclaredDependencies(Collections.emptyList(), false);
}
public static DeclaredDependencies closed(List<DeclaredDependency> dependencies) {
return new DeclaredDependencies(dependencies, true);
}
/**
* Creates a new {@link DeclaredDependencies} for the given {@link List} of {@link DeclaredDependency}.
*
* @param dependencies must not be {@literal null}.
*/
public DeclaredDependencies(List<DeclaredDependency> dependencies) {
private DeclaredDependencies(List<DeclaredDependency> dependencies, boolean closed) {
Assert.notNull(dependencies, "Dependencies must not be null!");
this.dependencies = dependencies;
this.closed = closed;
}
/**
* Returns whether any of the dependencies contains the given {@link JavaClass}.
* Returns whether the given {@link JavaClass} is a valid dependency.
*
* @param type must not be {@literal null}.
* @return
*/
public boolean contains(JavaClass type) {
Assert.notNull(type, "JavaClass must not be null!");
return dependencies.stream() //
.anyMatch(it -> it.contains(type));
public boolean isAllowedDependency(JavaClass type) {
return isAllowedDependency(it -> it.contains(type));
}
/**
* Returns whether the {@link DeclaredDependencies} are empty.
*/
public boolean isEmpty() {
return dependencies.isEmpty();
public boolean isAllowedDependency(Class<?> type) {
return isAllowedDependency(it -> it.contains(type));
}
private boolean isAllowedDependency(Predicate<DeclaredDependency> predicate) {
Assert.notNull(predicate, "Predicate must not be null!");
return closed ? !dependencies.isEmpty() && contains(predicate) : dependencies.isEmpty() || contains(predicate);
}
/*
@@ -742,9 +772,9 @@ public class ApplicationModule {
@Override
public String toString() {
return dependencies.stream() //
.map(DeclaredDependency::toString)
.collect(Collectors.joining(", "));
return dependencies.isEmpty() //
? "none" //
: dependencies.stream().map(DeclaredDependency::toString).collect(Collectors.joining(", "));
}
/*
@@ -773,6 +803,18 @@ public class ApplicationModule {
public int hashCode() {
return Objects.hash(dependencies);
}
/**
* Returns whether any of the dependencies contains the given {@link JavaClass}.
*
* @param type must not be {@literal null}.
*/
private boolean contains(Predicate<DeclaredDependency> condition) {
Assert.notNull(condition, "Condition must not be null!");
return dependencies.stream().anyMatch(condition);
}
}
static class QualifiedDependency {
@@ -880,16 +922,15 @@ public class ApplicationModule {
var originModule = getExistingModuleOf(source, modules);
var targetModule = getExistingModuleOf(target, modules);
DeclaredDependencies allowedTargets = originModule.getAllowedDependencies(modules);
DeclaredDependencies declaredDependencies = originModule.getDeclaredDependencies(modules);
Violations violations = Violations.NONE;
// Check explicitly defined allowed targets
if (!allowedTargets.isEmpty() && !allowedTargets.contains(target)) {
if (!declaredDependencies.isAllowedDependency(target)) {
var message = "Module '%s' depends on module '%s' via %s -> %s. Allowed targets: %s." //
.formatted(originModule.getName(), targetModule.getName(), source.getName(), target.getName(),
allowedTargets.toString());
declaredDependencies.toString());
return violations.and(new IllegalStateException(message));
}

View File

@@ -16,7 +16,6 @@
package org.springframework.modulith.core;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
@@ -67,7 +66,7 @@ interface ApplicationModuleInformation {
*
* @return will never be {@literal null}.
*/
List<String> getAllowedDependencies();
List<String> getDeclaredDependencies();
/**
* An {@link ApplicationModuleInformation} for the jMolecules {@link Module} annotation.
@@ -106,11 +105,11 @@ interface ApplicationModuleInformation {
/*
* (non-Javadoc)
* @see org.springframework.modulith.model.ApplicationModuleInformation#getAllowedDependencies()
* @see org.springframework.modulith.core.ApplicationModuleInformation#getDeclaredDependencies()
*/
@Override
public List<String> getAllowedDependencies() {
return Collections.emptyList();
public List<String> getDeclaredDependencies() {
return List.of(ApplicationModule.OPEN_TOKEN);
}
}
@@ -158,14 +157,14 @@ interface ApplicationModuleInformation {
/*
* (non-Javadoc)
* @see org.springframework.modulith.model.ApplicationModuleInformation#getAllowedDependencies()
* @see org.springframework.modulith.core.ApplicationModuleInformation#getDeclaredDependencies()
*/
@Override
public List<String> getAllowedDependencies() {
public List<String> getDeclaredDependencies() {
return annotation //
.map(it -> Arrays.stream(it.allowedDependencies())) //
.orElse(Stream.empty()) //
.orElse(Stream.of(ApplicationModule.OPEN_TOKEN)) //
.toList();
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.declared.first;
import example.declared.second.Second;
import org.springframework.stereotype.Component;
/**
* @author Oliver Drotbohm
*/
@Component
public class First {
First(Second second) {}
}

View File

@@ -0,0 +1,3 @@
// No dependencies allowed
@org.springframework.modulith.ApplicationModule(allowedDependencies = {})
package example.declared.first;

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.declared.fourth;
import org.springframework.stereotype.Component;
/**
* @author Oliver Drotbohm
*/
@Component
public class Fourth {
}

View File

@@ -0,0 +1,28 @@
/*
* 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.declared.second;
import example.declared.third.Third;
import org.springframework.stereotype.Component;
/**
* @author Oliver Drotbohm
*/
@Component
public class Second {
Second(Third third) {}
}

View File

@@ -0,0 +1,3 @@
// No explicit allowed dependencies -> all allowed
@org.springframework.modulith.ApplicationModule(displayName = "Second")
package example.declared.second;

View File

@@ -0,0 +1,28 @@
/*
* 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.declared.third;
import example.declared.fourth.Fourth;
import org.springframework.stereotype.Component;
/**
* @author Oliver Drotbohm
*/
@Component
public class Third {
Third(Fourth fourth) {}
}

View File

@@ -17,6 +17,11 @@ package org.springframework.modulith.core;
import static org.assertj.core.api.Assertions.*;
import example.declared.first.First;
import example.declared.fourth.Fourth;
import example.declared.second.Second;
import example.declared.third.Third;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -164,6 +169,22 @@ class ApplicationModulesIntegrationTest {
assertThat(source).containsExactly(ServiceComponentA.class, ServiceComponentB.class, String.class);
}
@Test // GH-267
void explicitEmptyAllowedModulesResultsInAllDependenciesRejected() {
var modules = ApplicationModules.of("example.declared");
var first = modules.getModuleByType(First.class).orElseThrow();
var second = modules.getModuleByType(Second.class).orElseThrow();
var third = modules.getModuleByType(Third.class).orElseThrow();
// Disallowed due to allowedDependencies = {}
assertThat(first.getDeclaredDependencies(modules).isAllowedDependency(Second.class)).isFalse();
// Allowed as allowedDependencies not set
assertThat(second.getDeclaredDependencies(modules).isAllowedDependency(Third.class)).isTrue();
assertThat(third.getDeclaredDependencies(modules).isAllowedDependency(Fourth.class)).isTrue();
}
private static void verifyNamedInterfaces(NamedInterfaces interfaces, String name, Class<?>... types) {
Stream.of(types).forEach(type -> {