diff --git a/cli/azd/.gitignore b/cli/azd/.gitignore index 83a4d0bbddc..03fed358981 100644 --- a/cli/azd/.gitignore +++ b/cli/azd/.gitignore @@ -7,3 +7,5 @@ resource.syso versioninfo.json azd.sln +**/target + diff --git a/cli/azd/internal/appdetect/appdetect_test.go b/cli/azd/internal/appdetect/appdetect_test.go index dcff83fdf68..dfc62d45dab 100644 --- a/cli/azd/internal/appdetect/appdetect_test.go +++ b/cli/azd/internal/appdetect/appdetect_test.go @@ -41,6 +41,20 @@ func TestDetect(t *testing.T) { Path: "java", DetectionRule: "Inferred by presence of: pom.xml", }, + { + Language: Java, + Path: "java-multimodules/application", + DetectionRule: "Inferred by presence of: pom.xml", + DatabaseDeps: []DatabaseDep{ + DbMySql, + DbPostgres, + }, + }, + { + Language: Java, + Path: "java-multimodules/library", + DetectionRule: "Inferred by presence of: pom.xml", + }, { Language: JavaScript, Path: "javascript", @@ -111,6 +125,20 @@ func TestDetect(t *testing.T) { Path: "java", DetectionRule: "Inferred by presence of: pom.xml", }, + { + Language: Java, + Path: "java-multimodules/application", + DetectionRule: "Inferred by presence of: pom.xml", + DatabaseDeps: []DatabaseDep{ + DbMySql, + DbPostgres, + }, + }, + { + Language: Java, + Path: "java-multimodules/library", + DetectionRule: "Inferred by presence of: pom.xml", + }, }, }, { @@ -130,6 +158,20 @@ func TestDetect(t *testing.T) { Path: "java", DetectionRule: "Inferred by presence of: pom.xml", }, + { + Language: Java, + Path: "java-multimodules/application", + DetectionRule: "Inferred by presence of: pom.xml", + DatabaseDeps: []DatabaseDep{ + DbMySql, + DbPostgres, + }, + }, + { + Language: Java, + Path: "java-multimodules/library", + DetectionRule: "Inferred by presence of: pom.xml", + }, }, }, { @@ -152,6 +194,20 @@ func TestDetect(t *testing.T) { Path: "java", DetectionRule: "Inferred by presence of: pom.xml", }, + { + Language: Java, + Path: "java-multimodules/application", + DetectionRule: "Inferred by presence of: pom.xml", + DatabaseDeps: []DatabaseDep{ + DbMySql, + DbPostgres, + }, + }, + { + Language: Java, + Path: "java-multimodules/library", + DetectionRule: "Inferred by presence of: pom.xml", + }, { Language: Python, Path: "python", diff --git a/cli/azd/internal/appdetect/java.go b/cli/azd/internal/appdetect/java.go index 0a5dfdac870..fe6fec3ea65 100644 --- a/cli/azd/internal/appdetect/java.go +++ b/cli/azd/internal/appdetect/java.go @@ -2,11 +2,18 @@ package appdetect import ( "context" + "encoding/xml" + "fmt" "io/fs" + "maps" + "os" + "path/filepath" + "slices" "strings" ) type javaDetector struct { + rootProjects []mavenProject } func (jd *javaDetector) Language() Language { @@ -16,13 +23,121 @@ func (jd *javaDetector) Language() Language { func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries []fs.DirEntry) (*Project, error) { for _, entry := range entries { if strings.ToLower(entry.Name()) == "pom.xml" { - return &Project{ + pomFile := filepath.Join(path, entry.Name()) + project, err := readMavenProject(pomFile) + if err != nil { + return nil, fmt.Errorf("error reading pom.xml: %w", err) + } + + if len(project.Modules) > 0 { + // This is a multi-module project, we will capture the analysis, but return nil + // to continue recursing + jd.rootProjects = append(jd.rootProjects, *project) + return nil, nil + } + + var currentRoot *mavenProject + for _, rootProject := range jd.rootProjects { + // we can say that the project is in the root project if the path is under the project + if inRoot := strings.HasPrefix(pomFile, rootProject.path); inRoot { + currentRoot = &rootProject + } + } + + _ = currentRoot // use currentRoot here in the analysis + result, err := detectDependencies(project, &Project{ Language: Java, Path: path, - DetectionRule: "Inferred by presence of: " + entry.Name(), - }, nil + DetectionRule: "Inferred by presence of: pom.xml", + }) + if err != nil { + return nil, fmt.Errorf("detecting dependencies: %w", err) + } + + return result, nil } } return nil, nil } + +// mavenProject represents the top-level structure of a Maven POM file. +type mavenProject struct { + XmlName xml.Name `xml:"project"` + Parent parent `xml:"parent"` + Modules []string `xml:"modules>module"` // Capture the modules + Dependencies []dependency `xml:"dependencies>dependency"` + DependencyManagement dependencyManagement `xml:"dependencyManagement"` + Build build `xml:"build"` + path string +} + +// Parent represents the parent POM if this project is a module. +type parent struct { + GroupId string `xml:"groupId"` + ArtifactId string `xml:"artifactId"` + Version string `xml:"version"` +} + +// Dependency represents a single Maven dependency. +type dependency struct { + GroupId string `xml:"groupId"` + ArtifactId string `xml:"artifactId"` + Version string `xml:"version"` + Scope string `xml:"scope,omitempty"` +} + +// DependencyManagement includes a list of dependencies that are managed. +type dependencyManagement struct { + Dependencies []dependency `xml:"dependencies>dependency"` +} + +// Build represents the build configuration which can contain plugins. +type build struct { + Plugins []plugin `xml:"plugins>plugin"` +} + +// Plugin represents a build plugin. +type plugin struct { + GroupId string `xml:"groupId"` + ArtifactId string `xml:"artifactId"` + Version string `xml:"version"` +} + +func readMavenProject(filePath string) (*mavenProject, error) { + bytes, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var project mavenProject + if err := xml.Unmarshal(bytes, &project); err != nil { + return nil, fmt.Errorf("parsing xml: %w", err) + } + + project.path = filepath.Dir(filePath) + + return &project, nil +} + +func detectDependencies(mavenProject *mavenProject, project *Project) (*Project, error) { + databaseDepMap := map[DatabaseDep]struct{}{} + for _, dep := range mavenProject.Dependencies { + if dep.GroupId == "com.mysql" && dep.ArtifactId == "mysql-connector-j" { + databaseDepMap[DbMySql] = struct{}{} + } + + if dep.GroupId == "org.postgresql" && dep.ArtifactId == "postgresql" { + databaseDepMap[DbPostgres] = struct{}{} + } + } + + if len(databaseDepMap) > 0 { + project.DatabaseDeps = slices.SortedFunc(maps.Keys(databaseDepMap), + func(a, b DatabaseDep) int { + return strings.Compare(string(a), string(b)) + }) + } + + return project, nil +} diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml b/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml new file mode 100644 index 00000000000..e4ddaa858b5 --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + com.example + application + 0.0.1-SNAPSHOT + application + Demo project for Spring Boot + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + com.example + library + ${project.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.mysql + mysql-connector-j + + + + org.postgresql + postgresql + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/java/com/example/multimodule/application/DemoApplication.java b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/java/com/example/multimodule/application/DemoApplication.java new file mode 100644 index 00000000000..de6d4e0c7ce --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/java/com/example/multimodule/application/DemoApplication.java @@ -0,0 +1,27 @@ +package com.example.multimodule.application; + +import com.example.multimodule.service.MyService; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication(scanBasePackages = "com.example.multimodule") +@RestController +public class DemoApplication { + + private final MyService myService; + + public DemoApplication(MyService myService) { + this.myService = myService; + } + + @GetMapping("/") + public String home() { + return myService.message(); + } + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/resources/application.properties b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/resources/application.properties new file mode 100644 index 00000000000..7c40093f75e --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/resources/application.properties @@ -0,0 +1 @@ +service.message=Hello, World diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/test/java/com/example/multimodule/application/DemoApplicationTest.java b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/test/java/com/example/multimodule/application/DemoApplicationTest.java new file mode 100644 index 00000000000..7ef7bd2ad19 --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/test/java/com/example/multimodule/application/DemoApplicationTest.java @@ -0,0 +1,23 @@ +package com.example.multimodule.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.example.multimodule.service.MyService; + +@SpringBootTest +public class DemoApplicationTest { + + @Autowired + private MyService myService; + + @Test + public void contextLoads() { + assertThat(myService.message()).isNotNull(); + } + +} diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/library/pom.xml b/cli/azd/internal/appdetect/testdata/java-multimodules/library/pom.xml new file mode 100644 index 00000000000..8a2db935b0f --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/library/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.2.2 + + + com.example + library + 0.0.1-SNAPSHOT + library + Demo project for Spring Boot + + + org.springframework.boot + spring-boot + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/MyService.java b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/MyService.java new file mode 100644 index 00000000000..06444562963 --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/MyService.java @@ -0,0 +1,19 @@ +package com.example.multimodule.service; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Service; + +@Service +@EnableConfigurationProperties(ServiceProperties.class) +public class MyService { + + private final ServiceProperties serviceProperties; + + public MyService(ServiceProperties serviceProperties) { + this.serviceProperties = serviceProperties; + } + + public String message() { + return this.serviceProperties.getMessage(); + } +} diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/ServiceProperties.java b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/ServiceProperties.java new file mode 100644 index 00000000000..7dd29b730e0 --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/ServiceProperties.java @@ -0,0 +1,20 @@ +package com.example.multimodule.service; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("service") +public class ServiceProperties { + + /** + * A message for the service. + */ + private String message; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/test/java/com/example/multimodule/service/MyServiceTest.java b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/test/java/com/example/multimodule/service/MyServiceTest.java new file mode 100644 index 00000000000..0a2a07cfeef --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/test/java/com/example/multimodule/service/MyServiceTest.java @@ -0,0 +1,26 @@ +package com.example.multimodule.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest("service.message=Hello") +public class MyServiceTest { + + @Autowired + private MyService myService; + + @Test + public void contextLoads() { + assertThat(myService.message()).isNotNull(); + } + + @SpringBootApplication + static class TestConfiguration { + } + +} diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/pom.xml b/cli/azd/internal/appdetect/testdata/java-multimodules/pom.xml new file mode 100644 index 00000000000..fa72a1aa4c8 --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/pom.xml @@ -0,0 +1,16 @@ + + + 4.0.0 + + org.springframework + gs-multi-module + 0.1.0 + pom + + + library + application + + +