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