Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<version>1.4.2</version>
<scope>test</scope>
</dependency>
<!-- Remove SLF4J warnings added by archunit -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.18</version>
<scope>test</scope>
</dependency>
</dependencies>

<repositories>
Expand Down
1 change: 1 addition & 0 deletions src/test/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@
requires org.eclipse.collections.impl;
requires org.junit.jupiter.api;
requires org.junit.jupiter.params;
requires com.tngtech.archunit;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2025-2026 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.assertj.eclipse.collections.api.arch;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static org.assertj.eclipse.collections.test.ExposedTypesInPublicApiContractArchCondition.notExposeTypesInPackage;

import org.junit.jupiter.api.Test;

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;

public class PublicApiExposedTypesTest {
@Test
void publicApiDoesNotExposeEclipseCollectionsImplTypes() {
JavaClasses importedClasses = new ClassFileImporter().importPackages("org.assertj.eclipse.collections");
ArchRule rule = classes().that()
.resideInAnyPackage(
"org.assertj.eclipse.collections.api..",
"org.assertj.eclipse.collections.error..",
"org.assertj.eclipse.collections.util.."
)
.should(notExposeTypesInPackage("org.eclipse.collections.impl.")
.as("not expose types contained in package org.eclipse.collections.impl"));

rule.check(importedClasses);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* Copyright 2025-2026 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.assertj.eclipse.collections.test;

import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaCodeUnit;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.domain.JavaParameterizedType;
import com.tngtech.archunit.core.domain.JavaType;
import com.tngtech.archunit.core.domain.properties.HasModifiers;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;

import java.util.HashSet;
import java.util.Set;

/**
* An ArchUnit condition that checks if the given package's types are exposed in the public API contract. The public
* contract refers to the following:
*
* <p>
* <ul>
* <li>Types used as a parent class</li>
* <li>Types used as an interface</li>
* <li>Types used as a public or protected field type</li>
* <li>Types used as a constructor argument</li>
* <li>Types used as a method argument or return type</li>
* </ul>
* </p>
*
* <p>These checks include parameterized types/generics.</p>
*/
public final class ExposedTypesInPublicApiContractArchCondition extends ArchCondition<JavaClass> {
private final String packageName;

/**
* Entry point for using this condition.
*
* @param packageName The package name to check for exposed types.
* @return An instance of ExposedTypesInPublicApiContractArchCondition.
*/
public static ExposedTypesInPublicApiContractArchCondition notExposeTypesInPackage(String packageName) {
return new ExposedTypesInPublicApiContractArchCondition(packageName, "not expose types contained in package %s", packageName);
}

private ExposedTypesInPublicApiContractArchCondition(String packageName, String description, Object... args) {
super(description, args);
this.packageName = packageName;
}

@Override
public void check(JavaClass item, ConditionEvents events) {
checkParentClass(item, events, packageName);
checkInterfaces(item, events, packageName);
checkFields(item, events, packageName);
checkConstructors(item, events, packageName);
checkMethods(item, events, packageName);
}

private static void checkParentClass(JavaClass javaClass, ConditionEvents events, String packageName) {
javaClass.getRawSuperclass()
.ifPresent(parentClass -> checkType(javaClass, parentClass, events, packageName, "parent class"));
}

private static void checkInterfaces(JavaClass javaClass, ConditionEvents events, String packageName) {
javaClass.getRawInterfaces().stream()
.filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected)
.forEach(interFaceType -> checkType(javaClass, interFaceType, events, packageName, "interface"));
}

private static void checkFields(JavaClass javaClass, ConditionEvents events, String packageName) {
javaClass.getFields().stream()
.filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected)
.forEach(field -> checkType(javaClass, field.getType(), events, packageName, "field " + field.getFullName()));
}

private static void checkConstructors(JavaClass javaClass, ConditionEvents events, String packageName) {
javaClass.getConstructors().stream()
.filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected)
.forEach(constructor -> checkParameterTypes(javaClass, constructor, events, packageName));
}

private static void checkMethods(JavaClass javaClass, ConditionEvents events, String packageName) {
javaClass.getMethods().stream()
.filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected)
.forEach(method -> {
checkType(javaClass, method.getReturnType(), events, packageName, "return type of " + method.getFullName());
checkParameterTypes(javaClass, method, events, packageName);
});
}

private static void checkParameterTypes(JavaClass owner, JavaCodeUnit codeUnit, ConditionEvents events, String packageName) {
codeUnit.getParameterTypes()
.forEach(parameterType -> checkType(owner, parameterType, events, packageName, "parameter of " + codeUnit.getFullName()));
}

private static void checkType(JavaClass owner, JavaType type, ConditionEvents events, String packageName, String usageDescription) {
if (isTypeContainedInPackage(type.toErasure(), packageName)) {
events.add(SimpleConditionEvent.violated(owner, "%s exposes %s in %s".formatted(owner.getFullName(), type.getName(), usageDescription)));
}
recursiveCheckType(owner, type, events, packageName, usageDescription, new HashSet<>());
}

/**
* Don't call this method directly. Use {@link #checkType(JavaClass, JavaType, ConditionEvents, String, String)}
* instead.
*/
private static void recursiveCheckType(JavaClass owner, JavaType type, ConditionEvents events,
String packageName, String usageDescription, Set<String> visitedTypes) {
if (!visitedTypes.add(type.getName())) {
return;
}

if (isTypeContainedInPackage(type.toErasure(), packageName)) {
events.add(SimpleConditionEvent.violated(owner,
"%s exposes %s in %s".formatted(owner.getFullName(), type.getName(), usageDescription)));
}

if (type instanceof JavaParameterizedType parameterizedType) {
parameterizedType.getActualTypeArguments()
.forEach(typeArgument -> recursiveCheckType(owner, typeArgument, events, packageName, usageDescription, visitedTypes));
}
}

public static boolean isPublicOrProtected(HasModifiers member) {
return member.getModifiers().contains(JavaModifier.PUBLIC) || member.getModifiers().contains(JavaModifier.PROTECTED);
}

private static boolean isTypeContainedInPackage(JavaClass type, String packageName) {
return type.getFullName().startsWith(packageName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright 2025-2026 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.assertj.eclipse.collections.test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.eclipse.collections.test.ExposedTypesInPublicApiContractArchCondition.notExposeTypesInPackage;

import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import org.junit.jupiter.api.Test;

import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ConditionEvents;

class ExposedTypesInPublicApiContractArchConditionTest {

private static final String FORBIDDEN_PACKAGE = "java.util.concurrent.";

@Test
void cleanClassProducesNoViolations() {
assertNoViolations(ValidClass.class);
}

@Test
void publicFieldWithForbiddenTypeIsFlagged() {
assertHasViolationContaining(ExposesForbiddenPublicField.class, "AtomicInteger");
}

@Test
void protectedFieldWithForbiddenTypeIsFlagged() {
assertHasViolationContaining(ExposesForbiddenProtectedField.class, "AtomicInteger");
}

@Test
void privateFieldWithForbiddenTypeIsNotFlagged() {
assertNoViolations(ValidPrivateField.class);
}

@Test
void publicMethodReturnTypeWithForbiddenTypeIsFlagged() {
assertHasViolationContaining(ExposesForbiddenReturnType.class, "AtomicInteger");
}

@Test
void publicMethodParameterWithForbiddenTypeIsFlagged() {
assertHasViolationContaining(ExposesForbiddenMethodParameter.class, "AtomicInteger");
}

@Test
void publicConstructorParameterWithForbiddenTypeIsFlagged() {
assertHasViolationContaining(ExposesForbiddenConstructorParameter.class, "AtomicInteger");
}

@Test
void forbiddenParentClassIsFlagged() {
assertHasViolationContaining(ExposesForbiddenParentClass.class, "AtomicReference");
}

@Test
void forbiddenInterfaceIsFlagged() {
assertHasViolationContaining(ExposesForbiddenInterface.class, "Callable");
}

@Test
void forbiddenGenericTypeArgumentIsFlagged() {
assertHasViolationContaining(ExposesForbiddenGenericArgumentInField.class, "AtomicInteger");
}

@Test
void deeplyNestedForbiddenGenericTypeArgumentIsFlagged() {
assertHasViolationContaining(ExposesDeeplyNestedGenericArgument.class, "AtomicInteger");
}

private static List<String> violationsFor(Class<?> fixture) {
JavaClass javaClass = new ClassFileImporter().importClass(fixture);
ConditionEvents events = ConditionEvents.Factory.create();
notExposeTypesInPackage(FORBIDDEN_PACKAGE).check(javaClass, events);
return events.getViolating().stream()
.flatMap(event -> event.getDescriptionLines().stream())
.toList();
}

private static void assertNoViolations(Class<?> fixture) {
assertThat(violationsFor(fixture)).isEmpty();
}

private static void assertHasViolationContaining(Class<?> fixture, String expectedSnippet) {
assertThat(violationsFor(fixture)).anyMatch(line -> line.contains(expectedSnippet));
}

@SuppressWarnings("unused")
static class ValidClass {
public String validField;

public String validMethod(int input) {
return String.valueOf(input);
}
}

static class ExposesForbiddenPublicField {
@SuppressWarnings("unused")
public AtomicInteger counter;
}

static class ExposesForbiddenProtectedField {
@SuppressWarnings("unused")
protected AtomicInteger counter;
}

static class ValidPrivateField {
@SuppressWarnings("unused")
private AtomicInteger counter;

public int value() {
return counter == null ? 0 : counter.get();
}
}

static class ExposesForbiddenReturnType {
@SuppressWarnings("unused")
public AtomicInteger create() {
return new AtomicInteger();
}
}

static class ExposesForbiddenMethodParameter {
@SuppressWarnings("unused")
public void consume(AtomicInteger value) {
// Do nothing
}
}

static class ExposesForbiddenConstructorParameter {
@SuppressWarnings("unused")
public ExposesForbiddenConstructorParameter(AtomicInteger value) {
// Do nothing
}
}

static class ExposesForbiddenParentClass extends AtomicReference<String> {}

abstract static class ExposesForbiddenInterface implements Callable<String> {}

static class ExposesForbiddenGenericArgumentInField {
@SuppressWarnings("unused")
public List<AtomicInteger> items;
}

static class ExposesDeeplyNestedGenericArgument {
@SuppressWarnings("unused")
public Map<String, List<Future<AtomicInteger>>> deeplyNested;
}
}
Loading