Skip to content

Commit 9f60d1d

Browse files
committed
Expose REST API endpoint to list workflow run events
Signed-off-by: nscuro <nscuro@protonmail.com>
1 parent 2a8c522 commit 9f60d1d

16 files changed

Lines changed: 614 additions & 134 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# This file is part of Dependency-Track.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
type: object
18+
properties:
19+
sequence_number:
20+
type: integer
21+
event:
22+
type: object
23+
additionalProperties: true
24+
required:
25+
- sequence_number
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# This file is part of Dependency-Track.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
type: object
18+
allOf:
19+
- $ref: "./paginated-response.yaml"
20+
properties:
21+
events:
22+
type: array
23+
items:
24+
$ref: "./list-workflow-run-events-response-item.yaml"
25+
required:
26+
- _pagination
27+
- events

api/src/main/openapi/openapi.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ paths:
158158
$ref: "./paths/workflow-instances__id_.yaml"
159159
/workflow-runs:
160160
$ref: "./paths/workflow-runs.yaml"
161+
/workflow-runs/{id}/events:
162+
$ref: "./paths/workflow-runs__id__events.yaml"
161163

162164
components:
163165
securitySchemes:
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# This file is part of Dependency-Track.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
get:
18+
operationId: listWorkflowRunEvents
19+
summary: List all events of a workflow run
20+
description: |-
21+
Returns a paginated list of workflow run events, sorted by sequence number.
22+
23+
**Note:** This is an internal API endpoint and may change without notice.
24+
25+
Requires the `SYSTEM_CONFIGURATION` or `SYSTEM_CONFIGURATION_READ` permission.
26+
tags:
27+
- Workflows
28+
parameters:
29+
- name: id
30+
description: ID of the workflow run
31+
in: path
32+
schema:
33+
type: string
34+
format: uuid
35+
required: true
36+
- name: from_sequence_number
37+
description: |-
38+
Sequence number of the last seen event.
39+
May be used to continuously poll for new events.
40+
Can not be used together with `page_token`.
41+
in: query
42+
schema:
43+
type: integer
44+
minimum:
45+
- $ref: "../components/parameters/pagination-limit.yaml"
46+
- $ref: "../components/parameters/page-token.yaml"
47+
- $ref: "../components/parameters/sort-direction.yaml"
48+
responses:
49+
"200":
50+
description: Paginated list of workflow run events
51+
content:
52+
application/json:
53+
schema:
54+
$ref: "../components/schemas/list-workflow-run-events-response.yaml"
55+
"400":
56+
$ref: "../components/responses/invalid-request-error.yaml"
57+
"401":
58+
$ref: "../components/responses/generic-unauthorized-error.yaml"
59+
"403":
60+
$ref: "../components/responses/generic-forbidden-error.yaml"
61+
default:
62+
$ref: "../components/responses/generic-error.yaml"

apiserver/src/main/java/org/dependencytrack/resources/v2/ResourceConfig.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@
2525
import alpine.server.filters.HeaderFilter;
2626
import alpine.server.filters.RequestIdFilter;
2727
import alpine.server.filters.RequestMdcEnrichmentFilter;
28+
import com.fasterxml.jackson.databind.ObjectMapper;
29+
import jakarta.ws.rs.ext.ContextResolver;
2830
import org.dependencytrack.cache.CacheManagerBinder;
2931
import org.dependencytrack.dex.DexEngineBinder;
3032
import org.dependencytrack.filters.JerseyMetricsApplicationEventListener;
3133
import org.dependencytrack.plugin.PluginManagerBinder;
3234
import org.dependencytrack.secret.SecretManagerBinder;
35+
import org.glassfish.hk2.utilities.binding.AbstractBinder;
3336
import org.glassfish.jersey.jackson.JacksonFeature;
3437
import org.glassfish.jersey.media.multipart.MultiPartFeature;
3538

@@ -50,6 +53,15 @@ public ResourceConfig() {
5053
property(PROVIDER_SCANNING_RECURSIVE, true);
5154
property(WADL_FEATURE_DISABLE, true);
5255

56+
final var objectMapper = new ObjectMapper();
57+
register((ContextResolver<ObjectMapper>) type -> objectMapper);
58+
register(new AbstractBinder() {
59+
@Override
60+
protected void configure() {
61+
bind(objectMapper).to(ObjectMapper.class);
62+
}
63+
});
64+
5365
register(ApiFilter.class);
5466
register(AuthenticationFeature.class);
5567
register(AuthorizationFeature.class);

apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,34 +19,47 @@
1919
package org.dependencytrack.resources.v2;
2020

2121
import alpine.server.auth.PermissionRequired;
22+
import com.fasterxml.jackson.core.type.TypeReference;
23+
import com.fasterxml.jackson.databind.ObjectMapper;
24+
import com.google.protobuf.util.JsonFormat;
2225
import jakarta.inject.Inject;
2326
import jakarta.ws.rs.NotFoundException;
2427
import jakarta.ws.rs.Path;
2528
import jakarta.ws.rs.core.Response;
2629
import org.dependencytrack.api.v2.WorkflowsApi;
30+
import org.dependencytrack.api.v2.model.ListWorkflowRunEventsResponse;
31+
import org.dependencytrack.api.v2.model.ListWorkflowRunEventsResponseItem;
2732
import org.dependencytrack.api.v2.model.ListWorkflowRunsResponse;
2833
import org.dependencytrack.api.v2.model.SortDirection;
2934
import org.dependencytrack.api.v2.model.WorkflowRunStatus;
3035
import org.dependencytrack.auth.Permissions;
3136
import org.dependencytrack.common.pagination.Page;
3237
import org.dependencytrack.dex.engine.api.DexEngine;
38+
import org.dependencytrack.dex.engine.api.WorkflowRunHistoryEntry;
3339
import org.dependencytrack.dex.engine.api.WorkflowRunMetadata;
40+
import org.dependencytrack.dex.engine.api.request.ListWorkflowRunHistoryRequest;
3441
import org.dependencytrack.dex.engine.api.request.ListWorkflowRunsRequest;
3542
import org.dependencytrack.resources.AbstractApiResource;
3643
import org.jspecify.annotations.NullMarked;
3744
import org.jspecify.annotations.Nullable;
3845

46+
import java.io.IOException;
47+
import java.io.UncheckedIOException;
3948
import java.time.Instant;
49+
import java.util.Map;
50+
import java.util.UUID;
4051

4152
@Path("/")
4253
@NullMarked
4354
public class WorkflowsResource extends AbstractApiResource implements WorkflowsApi {
4455

4556
private final DexEngine dexEngine;
57+
private final ObjectMapper objectMapper;
4658

4759
@Inject
48-
WorkflowsResource(DexEngine dexEngine) {
60+
WorkflowsResource(DexEngine dexEngine, ObjectMapper objectMapper) {
4961
this.dexEngine = dexEngine;
62+
this.objectMapper = objectMapper;
5063
}
5164

5265
@Override
@@ -105,11 +118,7 @@ public Response listWorkflowRuns(
105118
case "completed_at" -> ListWorkflowRunsRequest.SortBy.COMPLETED_AT;
106119
case null, default -> null;
107120
})
108-
.withSortDirection(switch (sortDirection) {
109-
case ASC -> org.dependencytrack.common.pagination.SortDirection.ASC;
110-
case DESC -> org.dependencytrack.common.pagination.SortDirection.DESC;
111-
case null -> null;
112-
})
121+
.withSortDirection(convert(sortDirection))
113122
.withPageToken(pageToken)
114123
.withLimit(limit));
115124

@@ -123,6 +132,35 @@ public Response listWorkflowRuns(
123132
return Response.ok(response).build();
124133
}
125134

135+
@Override
136+
@PermissionRequired({
137+
Permissions.Constants.SYSTEM_CONFIGURATION,
138+
Permissions.Constants.SYSTEM_CONFIGURATION_READ
139+
})
140+
public Response listWorkflowRunEvents(
141+
UUID id,
142+
@Nullable Integer fromSequenceNumber,
143+
Integer limit,
144+
@Nullable String pageToken,
145+
@Nullable SortDirection sortDirection) {
146+
final Page<WorkflowRunHistoryEntry> historyEntryPage =
147+
dexEngine.listRunHistory(
148+
new ListWorkflowRunHistoryRequest(id)
149+
.withFromSequenceNumber(fromSequenceNumber)
150+
.withSortDirection(convert(sortDirection))
151+
.withPageToken(pageToken)
152+
.withLimit(limit));
153+
154+
final var response = ListWorkflowRunEventsResponse.builder()
155+
.events(historyEntryPage.items().stream()
156+
.map(entry -> convert(entry, objectMapper))
157+
.toList())
158+
.pagination(createPaginationMetadata(getUriInfo(), historyEntryPage))
159+
.build();
160+
161+
return Response.ok(response).build();
162+
}
163+
126164
private static org.dependencytrack.dex.engine.api.@Nullable WorkflowRunStatus convert(@Nullable WorkflowRunStatus status) {
127165
return switch (status) {
128166
case CANCELLED -> org.dependencytrack.dex.engine.api.WorkflowRunStatus.CANCELLED;
@@ -170,4 +208,31 @@ private static org.dependencytrack.api.v2.model.WorkflowRunMetadata convert(Work
170208
.build();
171209
}
172210

211+
private static ListWorkflowRunEventsResponseItem convert(
212+
WorkflowRunHistoryEntry entry,
213+
ObjectMapper objectMapper) {
214+
final Map<String, Object> eventJsonMap;
215+
try {
216+
final String eventJson = JsonFormat.printer().print(entry.event());
217+
eventJsonMap = objectMapper.readValue(eventJson, new TypeReference<>() {
218+
});
219+
} catch (IOException e) {
220+
throw new UncheckedIOException(e);
221+
}
222+
223+
return ListWorkflowRunEventsResponseItem.builder()
224+
.sequenceNumber(entry.sequenceNumber())
225+
.event(eventJsonMap)
226+
.build();
227+
}
228+
229+
private static org.dependencytrack.common.pagination.@Nullable SortDirection convert(
230+
@Nullable SortDirection sortDirection) {
231+
return switch (sortDirection) {
232+
case ASC -> org.dependencytrack.common.pagination.SortDirection.ASC;
233+
case DESC -> org.dependencytrack.common.pagination.SortDirection.DESC;
234+
case null -> null;
235+
};
236+
}
237+
173238
}

apiserver/src/test/java/org/dependencytrack/resources/v2/WorkflowsResourceTest.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,23 @@
1818
*/
1919
package org.dependencytrack.resources.v2;
2020

21+
import com.google.protobuf.util.Timestamps;
2122
import jakarta.ws.rs.core.Response;
2223
import org.dependencytrack.JerseyTestExtension;
2324
import org.dependencytrack.ResourceTest;
2425
import org.dependencytrack.auth.Permissions;
2526
import org.dependencytrack.common.pagination.Page;
2627
import org.dependencytrack.common.pagination.Page.TotalCount;
2728
import org.dependencytrack.common.pagination.SortDirection;
29+
import org.dependencytrack.dex.api.payload.PayloadConverters;
2830
import org.dependencytrack.dex.engine.api.DexEngine;
31+
import org.dependencytrack.dex.engine.api.WorkflowRunHistoryEntry;
2932
import org.dependencytrack.dex.engine.api.WorkflowRunMetadata;
3033
import org.dependencytrack.dex.engine.api.WorkflowRunStatus;
34+
import org.dependencytrack.dex.engine.api.request.ListWorkflowRunHistoryRequest;
3135
import org.dependencytrack.dex.engine.api.request.ListWorkflowRunsRequest;
36+
import org.dependencytrack.dex.proto.event.v1.RunCompleted;
37+
import org.dependencytrack.dex.proto.event.v1.WorkflowEvent;
3238
import org.glassfish.jersey.inject.hk2.AbstractBinder;
3339
import org.junit.jupiter.api.AfterEach;
3440
import org.junit.jupiter.api.Test;
@@ -43,6 +49,7 @@
4349

4450
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
4551
import static org.assertj.core.api.Assertions.assertThat;
52+
import static org.dependencytrack.dex.proto.common.v1.WorkflowRunStatus.WORKFLOW_RUN_STATUS_COMPLETED;
4653
import static org.mockito.ArgumentMatchers.any;
4754
import static org.mockito.ArgumentMatchers.eq;
4855
import static org.mockito.Mockito.doReturn;
@@ -244,4 +251,60 @@ public void listWorkflowRunsShouldPassQueryParametersToDexEngine() {
244251
assertThat(capturedRequest.sortBy()).isEqualTo(ListWorkflowRunsRequest.SortBy.CREATED_AT);
245252
}
246253

254+
@Test
255+
public void listWorkflowRunHistoryShouldReturnWorkflowRunHistory() {
256+
initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_READ);
257+
258+
final var event = WorkflowEvent.newBuilder()
259+
.setId(-1)
260+
.setTimestamp(Timestamps.fromSeconds(666666))
261+
.setRunCompleted(RunCompleted.newBuilder()
262+
.setStatus(WORKFLOW_RUN_STATUS_COMPLETED)
263+
.setCustomStatus("customStatus")
264+
.setResult(PayloadConverters.stringConverter().convertToPayload("payload"))
265+
.build())
266+
.build();
267+
268+
doReturn(new Page<>(List.of(new WorkflowRunHistoryEntry(0, event)), null).withTotalCount(1, TotalCount.Type.EXACT))
269+
.when(DEX_ENGINE_MOCK).listRunHistory(any(ListWorkflowRunHistoryRequest.class));
270+
271+
final Response response = jersey.target("/workflow-runs/de10c1ec-959e-486d-a031-deb97963ff7c/events").request()
272+
.header(X_API_KEY, apiKey)
273+
.get();
274+
assertThat(response.getStatus()).isEqualTo(200);
275+
assertThatJson(getPlainTextBody(response))
276+
.isEqualTo(/* language=JSON */ """
277+
{
278+
"events": [
279+
{
280+
"sequence_number": 0,
281+
"event": {
282+
"id": -1,
283+
"timestamp": "1970-01-08T17:11:06Z",
284+
"runCompleted": {
285+
"status": "WORKFLOW_RUN_STATUS_COMPLETED",
286+
"customStatus": "customStatus",
287+
"result": {
288+
"binaryContent": {
289+
"mediaType": "text/plain",
290+
"data": "cGF5bG9hZA=="
291+
}
292+
}
293+
}
294+
}
295+
}
296+
],
297+
"_pagination": {
298+
"links": {
299+
"self": "${json-unit.any-string}"
300+
},
301+
"total": {
302+
"count": 1,
303+
"type": "EXACT"
304+
}
305+
}
306+
}
307+
""");
308+
}
309+
247310
}

0 commit comments

Comments
 (0)