Skip to content

Commit 609dd8d

Browse files
feat: add nested ancestor path to project API and populate on detail endpoints
- Nest all parents inside the parent block (parent.parent...) in API responses - Add parent to @JsonIncludeProperties for recursive serialization - Wire parent chain in populateAncestorPaths for nested structure - Populate ancestor path on single-project getProject endpoints (uuid, lookup, latest) - Add tests for nested parent chain and ancestor path across detail endpoints Signed-off-by: Valentijn Scholten <valentijnscholten@gmail.com>
1 parent 2495d8b commit 609dd8d

5 files changed

Lines changed: 185 additions & 3 deletions

File tree

src/main/java/org/dependencytrack/model/Project.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ public enum FetchGroup {
266266

267267
@Persistent
268268
@Column(name = "PARENT_PROJECT_ID")
269-
@JsonIncludeProperties(value = {"name", "version", "uuid"})
269+
@JsonIncludeProperties(value = {"name", "version", "uuid", "parent"})
270270
private Project parent;
271271

272272
@Persistent(mappedBy = "parent")

src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ public Project getProject(final String uuid) {
228228
project.setMetrics(getMostRecentProjectMetrics(project));
229229
// set ProjectVersions to minimize the number of round trips a client needs to make
230230
project.setVersions(getProjectVersions(project));
231+
populateAncestorPaths(List.of(project));
231232
}
232233
return project;
233234
}
@@ -259,6 +260,7 @@ public Project getProject(final String name, final String version) {
259260
project.setMetrics(getMostRecentProjectMetrics(project));
260261
// set ProjectVersions to prevent extra round trip
261262
project.setVersions(getProjectVersions(project));
263+
populateAncestorPaths(List.of(project));
262264
}
263265
return project;
264266
}
@@ -291,6 +293,7 @@ public Project getLatestProjectVersion(final String name) {
291293
project.setMetrics(getMostRecentProjectMetrics(project));
292294
// set ProjectVersions to prevent extra round trip
293295
project.setVersions(getProjectVersions(project));
296+
populateAncestorPaths(List.of(project));
294297
}
295298
return project;
296299
}

src/main/java/org/dependencytrack/persistence/QueryManager.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1722,8 +1722,35 @@ protected void populateAncestorPaths(List<Project> projects) {
17221722
depth++;
17231723
}
17241724

1725-
// Build the ancestor path for each project using the complete map
1726-
projects.forEach(project -> project.setAncestorPath(buildAncestorPathFromMap(project, projectMap)));
1725+
// Build the ancestor path and wire the parent chain for each project
1726+
projects.forEach(project -> {
1727+
final List<Project.AncestorPathElement> path = buildAncestorPathFromMap(project, projectMap);
1728+
project.setAncestorPath(path);
1729+
wireParentChain(project, path, projectMap);
1730+
});
1731+
}
1732+
1733+
/**
1734+
* Wires the parent chain on the Project entities so that serialization produces a nested
1735+
* parent structure (parent containing parent containing ...) rather than a flat list.
1736+
* Path is ordered from root to immediate parent.
1737+
*/
1738+
protected static void wireParentChain(Project project, List<Project.AncestorPathElement> path,
1739+
Map<UUID, Project> projectMap) {
1740+
if (path == null || path.size() < 2) {
1741+
return;
1742+
}
1743+
for (int i = path.size() - 1; i >= 1; i--) {
1744+
final Project ancestor = projectMap.get(path.get(i).uuid());
1745+
final Project ancestorParent = projectMap.get(path.get(i - 1).uuid());
1746+
if (ancestor != null && ancestorParent != null) {
1747+
ancestor.setParent(ancestorParent);
1748+
}
1749+
}
1750+
final Project root = projectMap.get(path.get(0).uuid());
1751+
if (root != null) {
1752+
root.setParent(null);
1753+
}
17271754
}
17281755

17291756
/**

src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,38 @@ public void testCloneProjectMetricUpdate() throws Exception {
132132
}
133133
}
134134

135+
@Test
136+
void testGetProjectPopulatesAncestorPathAndNestedParentChain() {
137+
final Project grandparent = qm.createProject("grandparent", null, "1.0", null, null, null, true, false);
138+
final Project parent = new Project();
139+
parent.setName("parent");
140+
parent.setVersion("1.0");
141+
parent.setParent(grandparent);
142+
qm.persist(parent);
143+
final Project project = new Project();
144+
project.setName("child");
145+
project.setVersion("1.0");
146+
project.setParent(parent);
147+
qm.persist(project);
148+
149+
final Project fetched = qm.getProject(project.getUuid().toString());
150+
Assertions.assertNotNull(fetched);
151+
152+
// Ancestor path is populated (root to immediate parent)
153+
final List<Project.AncestorPathElement> ancestorPath = fetched.getAncestorPath();
154+
Assertions.assertNotNull(ancestorPath);
155+
Assertions.assertEquals(2, ancestorPath.size());
156+
Assertions.assertEquals("grandparent", ancestorPath.get(0).name());
157+
Assertions.assertEquals("1.0", ancestorPath.get(0).version());
158+
Assertions.assertEquals("parent", ancestorPath.get(1).name());
159+
Assertions.assertEquals("1.0", ancestorPath.get(1).version());
160+
161+
// Nested parent chain is wired
162+
Assertions.assertNotNull(fetched.getParent());
163+
Assertions.assertEquals("parent", fetched.getParent().getName());
164+
Assertions.assertNotNull(fetched.getParent().getParent());
165+
Assertions.assertEquals("grandparent", fetched.getParent().getParent().getName());
166+
Assertions.assertNull(fetched.getParent().getParent().getParent());
167+
}
168+
135169
}

src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,39 @@ void getProjectLookupTest() {
226226
Assertions.assertEquals("100", json.getJsonArray("versions").getJsonObject(100).getString("version"));
227227
}
228228

229+
@Test
230+
void getProjectLookupReturnsNestedParentChainTest() {
231+
final var parentProject = new Project();
232+
parentProject.setName("acme-parent");
233+
parentProject.setVersion("2.0");
234+
qm.persist(parentProject);
235+
236+
final var project = new Project();
237+
project.setName("acme-lookup-child");
238+
project.setVersion("1.0");
239+
project.setParent(parentProject);
240+
qm.persist(project);
241+
242+
Response response = jersey.target(V1_PROJECT + "/lookup")
243+
.queryParam("name", "acme-lookup-child")
244+
.queryParam("version", "1.0")
245+
.request()
246+
.header(X_API_KEY, apiKey)
247+
.get();
248+
assertThat(response.getStatus()).isEqualTo(200);
249+
JsonObject json = parseJsonObject(response);
250+
assertThat(json).isNotNull();
251+
assertThat(json.getString("name")).isEqualTo("acme-lookup-child");
252+
assertThat(json.getString("version")).isEqualTo("1.0");
253+
254+
JsonObject parent = json.getJsonObject("parent");
255+
assertThat(parent).isNotNull();
256+
assertThat(parent.getString("name")).isEqualTo("acme-parent");
257+
assertThat(parent.getString("version")).isEqualTo("2.0");
258+
assertThat(parent.getString("uuid")).isEqualTo(parentProject.getUuid().toString());
259+
assertThat(parent.containsKey("parent")).isFalse(); // root, no further parent
260+
}
261+
229262
@Test
230263
void getProjectLookupNotFoundTest() {
231264
final var project = new Project();
@@ -383,6 +416,59 @@ void getProjectByUuidTest() {
383416
""");
384417
}
385418

419+
@Test
420+
void getProjectByUuidWithNestedParentChainTest() {
421+
final var grandparentProject = new Project();
422+
grandparentProject.setName("acme-org");
423+
grandparentProject.setVersion("1.0");
424+
qm.persist(grandparentProject);
425+
426+
final var parentProject = new Project();
427+
parentProject.setName("acme-app-parent");
428+
parentProject.setVersion("1.0.0");
429+
parentProject.setParent(grandparentProject);
430+
qm.persist(parentProject);
431+
432+
final var project = new Project();
433+
project.setName("acme-app");
434+
project.setVersion("1.0.0");
435+
project.setParent(parentProject);
436+
qm.persist(project);
437+
438+
Response response = jersey.target(V1_PROJECT + "/" + project.getUuid())
439+
.request()
440+
.header(X_API_KEY, apiKey)
441+
.get();
442+
assertThat(response.getStatus()).isEqualTo(200);
443+
JsonObject json = parseJsonObject(response);
444+
assertThat(json).isNotNull();
445+
446+
// Assert nested parent chain: project -> parent -> grandparent -> null
447+
JsonObject parent = json.getJsonObject("parent");
448+
assertThat(parent).isNotNull();
449+
assertThat(parent.getString("name")).isEqualTo("acme-app-parent");
450+
assertThat(parent.getString("version")).isEqualTo("1.0.0");
451+
assertThat(parent.getString("uuid")).isEqualTo(parentProject.getUuid().toString());
452+
453+
JsonObject grandparent = parent.getJsonObject("parent");
454+
assertThat(grandparent).isNotNull();
455+
assertThat(grandparent.getString("name")).isEqualTo("acme-org");
456+
assertThat(grandparent.getString("version")).isEqualTo("1.0");
457+
assertThat(grandparent.getString("uuid")).isEqualTo(grandparentProject.getUuid().toString());
458+
459+
// Root has no parent (key omitted with NON_NULL or null)
460+
assertThat(grandparent.containsKey("parent")).isFalse();
461+
462+
// Assert ancestorPath is also populated (flat list for backwards compatibility)
463+
JsonArray ancestorPath = json.getJsonArray("ancestorPath");
464+
assertThat(ancestorPath).isNotNull();
465+
assertThat(ancestorPath.size()).isEqualTo(2); // grandparent, parent (root to immediate)
466+
assertThat(ancestorPath.getJsonObject(0).getString("name")).isEqualTo("acme-org");
467+
assertThat(ancestorPath.getJsonObject(0).getString("version")).isEqualTo("1.0");
468+
assertThat(ancestorPath.getJsonObject(1).getString("name")).isEqualTo("acme-app-parent");
469+
assertThat(ancestorPath.getJsonObject(1).getString("version")).isEqualTo("1.0.0");
470+
}
471+
386472
@Test
387473
void getProjectByUuidNotPermittedTest() {
388474
enablePortfolioAccessControl();
@@ -2451,6 +2537,38 @@ void getLatestProjectTest() {
24512537
Assertions.assertEquals("1.0.2", json.getString("version"));
24522538
}
24532539

2540+
@Test
2541+
void getLatestProjectReturnsNestedParentChainTest() {
2542+
final var parentProject = new Project();
2543+
parentProject.setName("acme-latest-parent");
2544+
parentProject.setVersion("1.0");
2545+
qm.persist(parentProject);
2546+
2547+
qm.createProject("Acme Latest", null, "1.0.0", null, null, null, true, false);
2548+
final var latestProject = new Project();
2549+
latestProject.setName("Acme Latest");
2550+
latestProject.setVersion("1.0.2");
2551+
latestProject.setParent(parentProject);
2552+
latestProject.setIsLatest(true);
2553+
qm.persist(latestProject);
2554+
2555+
Response response = jersey.target(V1_PROJECT_LATEST + "Acme Latest")
2556+
.request()
2557+
.header(X_API_KEY, apiKey)
2558+
.get(Response.class);
2559+
assertThat(response.getStatus()).isEqualTo(200);
2560+
JsonObject json = parseJsonObject(response);
2561+
assertThat(json).isNotNull();
2562+
assertThat(json.getString("name")).isEqualTo("Acme Latest");
2563+
assertThat(json.getString("version")).isEqualTo("1.0.2");
2564+
2565+
JsonObject parent = json.getJsonObject("parent");
2566+
assertThat(parent).isNotNull();
2567+
assertThat(parent.getString("name")).isEqualTo("acme-latest-parent");
2568+
assertThat(parent.getString("version")).isEqualTo("1.0");
2569+
assertThat(parent.getString("uuid")).isEqualTo(parentProject.getUuid().toString());
2570+
}
2571+
24542572
@Test
24552573
void getLatestProjectWithAclEnabledTest() {
24562574
enablePortfolioAccessControl();

0 commit comments

Comments
 (0)