diff --git a/changelogs/feature/extend-sip-maven-plugin.json b/changelogs/feature/extend-sip-maven-plugin.json
new file mode 100644
index 0000000000..e6d0b0784a
--- /dev/null
+++ b/changelogs/feature/extend-sip-maven-plugin.json
@@ -0,0 +1,5 @@
+{
+ "author": "Nemikor",
+ "pullrequestId": "316",
+ "message": "Add a new goal declarative-structure-check in SIP Maven plugin, which checks whether declarative structure classes are valid."
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 8202e96927..95971eaae3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -78,6 +78,8 @@
32.0.1-jre
2.18.0
2.8
+ 3.27.1
+ 3.51.1
4.1.1
@@ -380,6 +382,18 @@
j2objc-annotations
${j2objc-annotations.version}
+
+
+ com.github.javaparser
+ javaparser-symbol-solver-core
+ ${java-parser.version}
+
+
+
+ org.checkerframework
+ checker-qual
+ ${checher-qual.version}
+
diff --git a/sip-maven-plugin/pom.xml b/sip-maven-plugin/pom.xml
index 2469337116..4d7591a386 100644
--- a/sip-maven-plugin/pom.xml
+++ b/sip-maven-plugin/pom.xml
@@ -52,6 +52,10 @@
maven-plugin-annotations
provided
+
+ com.github.javaparser
+ javaparser-symbol-solver-core
+
junit
junit
@@ -72,6 +76,11 @@
assertj-core
test
+
+ one.x1f.sip.foundation
+ sip-core
+ test
+
diff --git a/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/DeclarativeClassAnalyser.java b/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/DeclarativeClassAnalyser.java
new file mode 100644
index 0000000000..0279db6380
--- /dev/null
+++ b/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/DeclarativeClassAnalyser.java
@@ -0,0 +1,137 @@
+package one.x1f.sip.foundation.mvnplugin;
+
+import static one.x1f.sip.foundation.mvnplugin.DeclarativeStructureCheckMojo.X1F_SIP_GROUP;
+
+import com.github.javaparser.ast.NodeList;
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
+import com.github.javaparser.ast.expr.AnnotationExpr;
+import com.github.javaparser.ast.nodeTypes.NodeWithName;
+import com.github.javaparser.resolution.declarations.ResolvedReferenceTypeDeclaration;
+import com.github.javaparser.resolution.types.ResolvedReferenceType;
+import java.util.*;
+import one.x1f.sip.foundation.mvnplugin.model.ClassAnalysisResult;
+
+public class DeclarativeClassAnalyser {
+
+ private final Map expectedParentInterfaces =
+ Map.of(
+ "InboundConnector",
+ "one.x1f.sip.foundation.core.declarative.connector.InboundConnectorDefinition",
+ "OutboundConnector",
+ "one.x1f.sip.foundation.core.declarative.connector.OutboundConnectorDefinition",
+ "IntegrationScenario",
+ "one.x1f.sip.foundation.core.declarative.scenario.IntegrationScenarioDefinition",
+ "CompositeProcess",
+ "one.x1f.sip.foundation.core.declarative.process.CompositeProcessDefinition",
+ "ConnectorGroup",
+ "one.x1f.sip.foundation.core.declarative.connectorgroup.ConnectorGroupDefinition");
+
+ private final Map expectedAnnotationClasses =
+ Map.of(
+ "one.x1f.sip.foundation.core.declarative.connector.InboundConnectorDefinition",
+ "InboundConnector",
+ "one.x1f.sip.foundation.core.declarative.connector.OutboundConnectorDefinition",
+ "OutboundConnector",
+ "one.x1f.sip.foundation.core.declarative.scenario.IntegrationScenarioDefinition",
+ "IntegrationScenario",
+ "one.x1f.sip.foundation.core.declarative.process.CompositeProcessDefinition",
+ "CompositeProcess",
+ "one.x1f.sip.foundation.core.declarative.connectorgroup.ConnectorGroupDefinition",
+ "ConnectorGroup");
+
+ public ClassAnalysisResult doAnalysis(ClassOrInterfaceDeclaration clazz) {
+ var annotations = clazz.getAnnotations();
+ var resolved = clazz.resolve();
+ List ancestors = new ArrayList<>();
+ resolved.getAncestors().forEach(ancestor -> addResolvableAncestor(ancestor, ancestors));
+
+ return analyseDeclarativeStructure(resolved, annotations, ancestors);
+ }
+
+ private void addResolvableAncestor(
+ ResolvedReferenceType current, List ancestors) {
+ try {
+ String name = current.getQualifiedName();
+
+ if (!name.startsWith(X1F_SIP_GROUP)) return;
+
+ ancestors.add(current);
+ current
+ .getTypeDeclaration()
+ .ifPresent(
+ typeDeclaration ->
+ typeDeclaration
+ .getAncestors()
+ .forEach(ancestor -> addResolvableAncestor(ancestor, ancestors)));
+ } catch (Exception e) {
+ // skip because non resolvable
+ }
+ }
+
+ private ClassAnalysisResult analyseDeclarativeStructure(
+ ResolvedReferenceTypeDeclaration resolved,
+ NodeList annotations,
+ List ancestors) {
+ List annotationNames = annotations.stream().map(NodeWithName::getNameAsString).toList();
+ ClassAnalysisResult result = analyseAnnotations(resolved, ancestors, annotationNames);
+ if (result != null) return result;
+ result = analyseAncestors(resolved, ancestors, annotationNames);
+ if (result != null) return result;
+
+ return new ClassAnalysisResult(false, resolved.getQualifiedName());
+ }
+
+ private ClassAnalysisResult analyseAncestors(
+ ResolvedReferenceTypeDeclaration resolved,
+ List ancestors,
+ List annotationNames) {
+ List invalidAncestorMatches =
+ ancestors.stream()
+ .map(ResolvedReferenceType::getQualifiedName)
+ .filter(expectedAnnotationClasses::containsKey)
+ .filter(
+ ancestor -> !doesImplementedClassHaveMatchingAnnotation(annotationNames, ancestor))
+ .toList();
+ if (!invalidAncestorMatches.isEmpty()) {
+ String ancestorName = invalidAncestorMatches.get(0);
+ String second =
+ String.format(
+ "Class %s must be annotated with @%s to match the required base class.",
+ resolved.getQualifiedName(), expectedAnnotationClasses.get(ancestorName));
+ return new ClassAnalysisResult(true, second);
+ }
+ return null;
+ }
+
+ private ClassAnalysisResult analyseAnnotations(
+ ResolvedReferenceTypeDeclaration resolved,
+ List ancestors,
+ List annotationNames) {
+ List annotationMatches =
+ annotationNames.stream().filter(expectedParentInterfaces::containsKey).toList();
+ if (!annotationMatches.isEmpty()) {
+ String annotationName = annotationMatches.get(0);
+ if (!doesAnnotatedClassHaveMatchingParent(ancestors, annotationName)) {
+ String message =
+ String.format(
+ "Class %s annotated with @%s does not extend the required base type.",
+ resolved.getQualifiedName(), annotationName);
+ return new ClassAnalysisResult(true, message);
+ }
+ }
+ return null;
+ }
+
+ private boolean doesAnnotatedClassHaveMatchingParent(
+ List ancestors, String annotationName) {
+ return ancestors.stream()
+ .anyMatch(i -> i.getQualifiedName().equals(expectedParentInterfaces.get(annotationName)));
+ }
+
+ private boolean doesImplementedClassHaveMatchingAnnotation(
+ List annotations, String interfaceName) {
+ return annotations.stream()
+ .anyMatch(
+ annotationName -> annotationName.equals(expectedAnnotationClasses.get(interfaceName)));
+ }
+}
diff --git a/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/DeclarativeStructureCheckMojo.java b/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/DeclarativeStructureCheckMojo.java
new file mode 100644
index 0000000000..673ab5ed3e
--- /dev/null
+++ b/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/DeclarativeStructureCheckMojo.java
@@ -0,0 +1,122 @@
+package one.x1f.sip.foundation.mvnplugin;
+
+import com.github.javaparser.JavaParser;
+import com.github.javaparser.ParserConfiguration;
+import com.github.javaparser.ast.CompilationUnit;
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
+import com.github.javaparser.symbolsolver.JavaSymbolSolver;
+import com.github.javaparser.symbolsolver.resolution.typesolvers.*;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Stream;
+import one.x1f.sip.foundation.mvnplugin.model.ClassAnalysisResult;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+
+@Mojo(
+ name = "declarative-structure-check",
+ defaultPhase = LifecyclePhase.COMPILE,
+ requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
+public class DeclarativeStructureCheckMojo extends AbstractMojo {
+
+ public static final String X1F_SIP_GROUP = "one.x1f.sip";
+ public static final String JAVA_EXTENSION = ".java";
+ public static final String JAR_EXTENSION = ".jar";
+ public static final String APACHE_CAMEL_GROUP = "org.apache.camel";
+
+ @Parameter(defaultValue = "${project}", required = true, readonly = true)
+ MavenProject mavenProject;
+
+ @Parameter(defaultValue = "${project.basedir}/src/main/java")
+ private File sourceDir;
+
+ private final DeclarativeClassAnalyser declarativeClassAnalyser = new DeclarativeClassAnalyser();
+
+ @Override
+ public void execute() throws MojoExecutionException {
+ try {
+ CombinedTypeSolver typeSolver = new CombinedTypeSolver();
+ typeSolver.add(new ReflectionTypeSolver());
+ typeSolver.add(new JavaParserTypeSolver(sourceDir));
+
+ for (Artifact dep : mavenProject.getArtifacts()) {
+ if (!dep.getGroupId().startsWith(X1F_SIP_GROUP)
+ && !dep.getGroupId().startsWith(APACHE_CAMEL_GROUP)) continue;
+ File file = dep.getFile();
+ if (file != null && file.exists() && file.getName().endsWith(JAR_EXTENSION)) {
+ typeSolver.add(new JarTypeSolver(file));
+ } else if (file != null && file.exists() && file.getAbsolutePath().contains("sip-core")) {
+ typeSolver.add(new JavaParserTypeSolver(file));
+ }
+ }
+
+ ParserConfiguration config =
+ new ParserConfiguration().setSymbolResolver(new JavaSymbolSolver(typeSolver));
+
+ JavaParser parser = new JavaParser(config);
+
+ try (Stream pathStream = Files.walk(sourceDir.toPath())) {
+ var results =
+ pathStream
+ .filter(p -> p.toString().endsWith(JAVA_EXTENSION))
+ .map(path -> analyseClass(parser, path))
+ .toList();
+ validate(results);
+ }
+ } catch (Exception e) {
+ throw new MojoExecutionException("Error analysing declarative structure.", e);
+ }
+ }
+
+ private ClassAnalysisResult analyseClass(JavaParser parser, Path path) {
+ try {
+ Optional result = parser.parse(path).getResult();
+ if (result.isEmpty()) {
+ return new ClassAnalysisResult(false, null);
+ }
+ for (ClassOrInterfaceDeclaration clazz :
+ result.get().findAll(ClassOrInterfaceDeclaration.class)) {
+ var classAnalysisResult = declarativeClassAnalyser.doAnalysis(clazz);
+ if (classAnalysisResult.error()) {
+ return classAnalysisResult;
+ }
+ }
+ } catch (Exception e) {
+ getLog().warn("Failed to parse " + path + ": " + e.getMessage());
+ }
+ return new ClassAnalysisResult(false, null);
+ }
+
+ private void validate(List analyseResult) throws MojoExecutionException {
+ boolean hasErrors = false;
+
+ for (ClassAnalysisResult res : analyseResult) {
+ if (res.error()) {
+ getLog().error(res.message());
+ hasErrors = true;
+ }
+ }
+
+ if (hasErrors) {
+ throw new MojoExecutionException("Validation failed.");
+ } else {
+ getLog().info("Declarative Structure is valid.");
+ }
+ }
+
+ protected void setMavenProject(MavenProject mavenProject) {
+ this.mavenProject = mavenProject;
+ }
+
+ protected void setSourceDir(File sourceDir) {
+ this.sourceDir = sourceDir;
+ }
+}
diff --git a/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/model/ClassAnalysisResult.java b/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/model/ClassAnalysisResult.java
new file mode 100644
index 0000000000..1ce6e8396d
--- /dev/null
+++ b/sip-maven-plugin/src/main/java/one/x1f/sip/foundation/mvnplugin/model/ClassAnalysisResult.java
@@ -0,0 +1,3 @@
+package one.x1f.sip.foundation.mvnplugin.model;
+
+public record ClassAnalysisResult(boolean error, String message) {}
diff --git a/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/NoAnnotationConnector.java b/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/NoAnnotationConnector.java
new file mode 100644
index 0000000000..b8302d6c10
--- /dev/null
+++ b/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/NoAnnotationConnector.java
@@ -0,0 +1,62 @@
+package one.x1f.sip.foundation.connectors.con1;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Optional;
+import one.x1f.sip.foundation.core.declarative.connector.OutboundConnectorDefinition;
+import one.x1f.sip.foundation.core.declarative.orchestration.Orchestrator;
+import one.x1f.sip.foundation.core.declarative.orchestration.connector.ConnectorOrchestrationInfo;
+import org.apache.camel.model.RouteDefinition;
+
+public class NoAnnotationConnector implements OutboundConnectorDefinition {
+
+ @Override
+ public void defineOutboundEndpoints(RouteDefinition routeDefinition) {
+ // test
+ }
+
+ @Override
+ public String getConnectorGroupId() {
+ return "";
+ }
+
+ @Override
+ public String getScenarioId() {
+ return "";
+ }
+
+ @Override
+ public Class> getRequestModelClass() {
+ return null;
+ }
+
+ @Override
+ public Optional> getResponseModelClass() {
+ return Optional.empty();
+ }
+
+ @Override
+ public String[] getConfigurationIds() {
+ return new String[0];
+ }
+
+ @Override
+ public List getOnExceptionHandler() {
+ return List.of();
+ }
+
+ @Override
+ public String getId() {
+ return "";
+ }
+
+ @Override
+ public String getPathToDocumentationResource() {
+ return "";
+ }
+
+ @Override
+ public Orchestrator getOrchestrator() {
+ return null;
+ }
+}
diff --git a/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/NoParentConnector.java b/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/NoParentConnector.java
new file mode 100644
index 0000000000..03c8590585
--- /dev/null
+++ b/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/NoParentConnector.java
@@ -0,0 +1,9 @@
+package one.x1f.sip.foundation.connectors.con1;
+
+import one.x1f.sip.foundation.core.declarative.annotation.InboundConnector;
+
+@InboundConnector(
+ connectorGroup = "test",
+ integrationScenario = "test",
+ requestModel = Object.class)
+public class NoParentConnector {}
diff --git a/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/ValidConnector.java b/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/ValidConnector.java
new file mode 100644
index 0000000000..5a2eda4ad5
--- /dev/null
+++ b/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/connectors/con1/ValidConnector.java
@@ -0,0 +1,77 @@
+package one.x1f.sip.foundation.connectors.con1;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Optional;
+import one.x1f.sip.foundation.core.declarative.DeclarationsRegistry;
+import one.x1f.sip.foundation.core.declarative.RoutesRegistry;
+import one.x1f.sip.foundation.core.declarative.annotation.InboundConnector;
+import one.x1f.sip.foundation.core.declarative.connector.InboundConnectorDefinition;
+import one.x1f.sip.foundation.core.declarative.orchestration.Orchestrator;
+import one.x1f.sip.foundation.core.declarative.orchestration.connector.ConnectorOrchestrationInfo;
+import org.apache.camel.model.OptionalIdentifiedDefinition;
+
+@InboundConnector(
+ connectorGroup = "test",
+ integrationScenario = "test",
+ requestModel = Object.class)
+public class ValidConnector implements InboundConnectorDefinition {
+ @Override
+ public void defineInboundEndpoints(
+ OptionalIdentifiedDefinition definition,
+ String targetToBase,
+ RoutesRegistry routeRegistry,
+ DeclarationsRegistry declarationsRegistry) {
+ // test
+ }
+
+ @Override
+ public String getConnectorGroupId() {
+ return "";
+ }
+
+ @Override
+ public String getScenarioId() {
+ return "";
+ }
+
+ @Override
+ public Class> getRequestModelClass() {
+ return null;
+ }
+
+ @Override
+ public Optional> getResponseModelClass() {
+ return Optional.empty();
+ }
+
+ @Override
+ public String[] getConfigurationIds() {
+ return new String[0];
+ }
+
+ @Override
+ public List getOnExceptionHandler() {
+ return List.of();
+ }
+
+ @Override
+ public Class getEndpointDefinitionTypeClass() {
+ return null;
+ }
+
+ @Override
+ public String getId() {
+ return "";
+ }
+
+ @Override
+ public String getPathToDocumentationResource() {
+ return "";
+ }
+
+ @Override
+ public Orchestrator getOrchestrator() {
+ return null;
+ }
+}
diff --git a/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/mvnplugin/DeclarativeStructureCheckMojoTest.java b/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/mvnplugin/DeclarativeStructureCheckMojoTest.java
new file mode 100644
index 0000000000..cbd8c75cf7
--- /dev/null
+++ b/sip-maven-plugin/src/test/java/one/x1f/sip/foundation/mvnplugin/DeclarativeStructureCheckMojoTest.java
@@ -0,0 +1,58 @@
+package one.x1f.sip.foundation.mvnplugin;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.*;
+
+import java.io.File;
+import java.util.Set;
+import org.apache.maven.artifact.DefaultArtifact;
+import org.apache.maven.artifact.handler.DefaultArtifactHandler;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.logging.Log;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class DeclarativeStructureCheckMojoTest {
+ private final DeclarativeStructureCheckMojo subject = new DeclarativeStructureCheckMojo();
+ private Log log;
+
+ @BeforeEach
+ void setUpMocks() {
+ MavenProject mavenProject = mock(MavenProject.class);
+ log = mock(Log.class);
+ subject.setLog(log);
+ File sipCore = new File("../sip-core/src/main/java");
+ DefaultArtifact artifact =
+ new DefaultArtifact(
+ "one.x1f.sip.foundation",
+ sipCore.getName(),
+ "1.0",
+ "compile",
+ "jar",
+ "",
+ new DefaultArtifactHandler("jar"));
+ artifact.setFile(sipCore);
+ when(mavenProject.getArtifacts()).thenReturn(Set.of(artifact));
+
+ subject.setMavenProject(mavenProject);
+ }
+
+ @Test
+ void When_InvalidDeclarativeStructure_Expect_MojoExecutionException() {
+ subject.setSourceDir(new File("src/test/java"));
+ MojoExecutionException ex = assertThrows(MojoExecutionException.class, subject::execute);
+ assertEquals("Error analysing declarative structure.", ex.getMessage());
+
+ verify(log, times(2)).error(anyString());
+ verify(log)
+ .error(
+ contains(
+ "Class one.x1f.sip.foundation.connectors.con1.NoParentConnector annotated with @InboundConnector does not extend the required base type."));
+ verify(log)
+ .error(
+ contains(
+ "Class one.x1f.sip.foundation.connectors.con1.NoAnnotationConnector must be annotated with @OutboundConnector to match the required base class."));
+ }
+}
diff --git a/sip-starter-parent/pom.xml b/sip-starter-parent/pom.xml
index 8f60460544..0ed2f0a291 100644
--- a/sip-starter-parent/pom.xml
+++ b/sip-starter-parent/pom.xml
@@ -167,6 +167,7 @@
connectors-cross-dependencies-check
+ declarative-structure-check