1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 package org.opencastproject.external.endpoint;
22
23 import static org.apache.commons.lang3.StringUtils.isBlank;
24 import static org.apache.commons.lang3.StringUtils.isNoneBlank;
25 import static org.opencastproject.index.service.util.JSONUtils.safeString;
26 import static org.opencastproject.util.RestUtil.getEndpointUrl;
27 import static org.opencastproject.util.doc.rest.RestParameter.Type.BOOLEAN;
28 import static org.opencastproject.util.doc.rest.RestParameter.Type.INTEGER;
29 import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
30
31 import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
32 import org.opencastproject.elasticsearch.index.objects.event.Event;
33 import org.opencastproject.external.common.ApiMediaType;
34 import org.opencastproject.external.common.ApiResponseBuilder;
35 import org.opencastproject.external.common.ApiVersion;
36 import org.opencastproject.index.service.api.IndexService;
37 import org.opencastproject.mediapackage.MediaPackage;
38 import org.opencastproject.rest.RestConstants;
39 import org.opencastproject.security.api.UnauthorizedException;
40 import org.opencastproject.systems.OpencastConstants;
41 import org.opencastproject.util.NotFoundException;
42 import org.opencastproject.util.RestUtil;
43 import org.opencastproject.util.UrlSupport;
44 import org.opencastproject.util.data.Tuple;
45 import org.opencastproject.util.doc.rest.RestParameter;
46 import org.opencastproject.util.doc.rest.RestQuery;
47 import org.opencastproject.util.doc.rest.RestResponse;
48 import org.opencastproject.util.doc.rest.RestService;
49 import org.opencastproject.workflow.api.RetryStrategy;
50 import org.opencastproject.workflow.api.WorkflowDefinition;
51 import org.opencastproject.workflow.api.WorkflowInstance;
52 import org.opencastproject.workflow.api.WorkflowOperationInstance;
53 import org.opencastproject.workflow.api.WorkflowService;
54 import org.opencastproject.workflow.api.WorkflowStateException;
55
56 import com.google.gson.JsonArray;
57 import com.google.gson.JsonElement;
58 import com.google.gson.JsonObject;
59 import com.google.gson.JsonPrimitive;
60
61 import org.json.simple.JSONObject;
62 import org.json.simple.parser.JSONParser;
63 import org.json.simple.parser.ParseException;
64 import org.osgi.service.component.ComponentContext;
65 import org.osgi.service.component.annotations.Activate;
66 import org.osgi.service.component.annotations.Component;
67 import org.osgi.service.component.annotations.Reference;
68 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
69 import org.slf4j.Logger;
70 import org.slf4j.LoggerFactory;
71
72 import java.net.URI;
73 import java.time.ZoneOffset;
74 import java.time.format.DateTimeFormatter;
75 import java.util.HashMap;
76 import java.util.Map;
77 import java.util.Optional;
78
79 import javax.servlet.http.HttpServletResponse;
80 import javax.ws.rs.DELETE;
81 import javax.ws.rs.FormParam;
82 import javax.ws.rs.GET;
83 import javax.ws.rs.HeaderParam;
84 import javax.ws.rs.POST;
85 import javax.ws.rs.PUT;
86 import javax.ws.rs.Path;
87 import javax.ws.rs.PathParam;
88 import javax.ws.rs.Produces;
89 import javax.ws.rs.QueryParam;
90 import javax.ws.rs.core.Response;
91
92 @Path("/api/workflows")
93 @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0, ApiMediaType.VERSION_1_3_0,
94 ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0, ApiMediaType.VERSION_1_6_0,
95 ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0, ApiMediaType.VERSION_1_9_0,
96 ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
97 @RestService(name = "externalapiworkflowinstances", title = "External API Workflow Instances Service", notes = {},
98 abstractText = "Provides resources and operations related to the workflow instances")
99 @Component(
100 immediate = true,
101 service = WorkflowsEndpoint.class,
102 property = {
103 "service.description=External API - Workflow Instances Endpoint",
104 "opencast.service.type=org.opencastproject.external.workflows.instances",
105 "opencast.service.path=/api/workflows"
106 }
107 )
108 @JaxrsResource
109 public class WorkflowsEndpoint {
110
111 private static final Logger logger = LoggerFactory.getLogger(WorkflowsEndpoint.class);
112
113
114 protected String endpointBaseUrl;
115
116
117 private WorkflowService workflowService;
118 private ElasticsearchIndex elasticsearchIndex;
119 private IndexService indexService;
120
121
122 @Reference
123 public void setWorkflowService(WorkflowService workflowService) {
124 this.workflowService = workflowService;
125 }
126
127
128 @Reference
129 public void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
130 this.elasticsearchIndex = elasticsearchIndex;
131 }
132
133
134 @Reference
135 public void setIndexService(IndexService indexService) {
136 this.indexService = indexService;
137 }
138
139
140
141
142 @Activate
143 void activate(ComponentContext cc) {
144 logger.info("Activating External API - Workflow Instances Endpoint");
145
146 final Tuple<String, String> endpointUrl = getEndpointUrl(cc, OpencastConstants.EXTERNAL_API_URL_ORG_PROPERTY,
147 RestConstants.SERVICE_PATH_PROPERTY);
148 endpointBaseUrl = UrlSupport.concat(endpointUrl.getA(), endpointUrl.getB());
149 logger.debug("Configured service endpoint is {}", endpointBaseUrl);
150 }
151
152 @POST
153 @Path("")
154 @RestQuery(
155 name = "createworkflowinstance",
156 description = "Creates a workflow instance.",
157 returnDescription = "",
158 restParameters = {
159 @RestParameter(name = "event_identifier", description = "The event identifier this workflow should run "
160 + "against", isRequired = true, type = STRING),
161 @RestParameter(name = "workflow_definition_identifier", description = "The identifier of the workflow "
162 + "definition to use", isRequired = true, type = STRING),
163 @RestParameter(name = "configuration", description = "The optional configuration for this workflow",
164 isRequired = false, type = STRING),
165 @RestParameter(name = "withoperations", description = "Whether the workflow operations should be included in "
166 + "the response", isRequired = false, type = BOOLEAN),
167 @RestParameter(name = "withconfiguration", description = "Whether the workflow configuration should be "
168 + "included in the response", isRequired = false, type = BOOLEAN),
169 },
170 responses = {
171 @RestResponse(description = "A new workflow is created and its identifier is returned in the Location "
172 + "header.", responseCode = HttpServletResponse.SC_CREATED),
173 @RestResponse(description = "The request is invalid or inconsistent.",
174 responseCode = HttpServletResponse.SC_BAD_REQUEST),
175 @RestResponse(description = "The event or workflow definition could not be found.",
176 responseCode = HttpServletResponse.SC_NOT_FOUND)
177 })
178 public Response createWorkflowInstance(@HeaderParam("Accept") String acceptHeader,
179 @FormParam("event_identifier") String eventId,
180 @FormParam("workflow_definition_identifier") String workflowDefinitionIdentifier,
181 @FormParam("configuration") String configuration, @QueryParam("withoperations") boolean withOperations,
182 @QueryParam("withconfiguration") boolean withConfiguration) {
183 if (isBlank(eventId)) {
184 return RestUtil.R.badRequest("Required parameter 'event_identifier' is missing or invalid");
185 }
186
187 if (isBlank(workflowDefinitionIdentifier)) {
188 return RestUtil.R.badRequest("Required parameter 'workflow_definition_identifier' is missing or invalid");
189 }
190
191 try {
192
193 Optional<Event> event = indexService.getEvent(eventId, elasticsearchIndex);
194 if (event.isEmpty()) {
195 return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", eventId);
196 }
197 MediaPackage mp = indexService.getEventMediapackage(event.get());
198
199
200 WorkflowDefinition wd;
201 try {
202 wd = workflowService.getWorkflowDefinitionById(workflowDefinitionIdentifier);
203 } catch (NotFoundException e) {
204 return ApiResponseBuilder.notFound("Cannot find a workflow definition with id '%s'.",
205 workflowDefinitionIdentifier);
206 }
207
208
209 Map<String, String> properties = new HashMap<>();
210 if (isNoneBlank(configuration)) {
211 JSONParser parser = new JSONParser();
212 try {
213 properties.putAll((JSONObject) parser.parse(configuration));
214 } catch (ParseException e) {
215 return RestUtil.R.badRequest("Passed parameter 'configuration' is invalid JSON.");
216 }
217 }
218
219
220 WorkflowInstance wi = workflowService.start(wd, mp, null, properties);
221 return ApiResponseBuilder.Json.created(acceptHeader, URI.create(getWorkflowUrl(wi.getId())),
222 workflowInstanceToJSON(wi, withOperations, withConfiguration));
223 } catch (IllegalStateException e) {
224 final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
225 JsonObject json = new JsonObject();
226 json.addProperty("message", safeString(e.getMessage()));
227 return ApiResponseBuilder.Json.conflict(requestedVersion, json);
228 } catch (Exception e) {
229 logger.error("Could not create workflow instances", e);
230 return ApiResponseBuilder.serverError("Could not create workflow instances, reason: '%s'", e.getMessage());
231 }
232 }
233
234 @GET
235 @Path("{workflowInstanceId}")
236 @RestQuery(
237 name = "getworkflowinstance",
238 description = "Returns a single workflow instance.",
239 returnDescription = "",
240 pathParameters = {
241 @RestParameter(name = "workflowInstanceId", description = "The workflow instance id", isRequired = true,
242 type = INTEGER)
243 },
244 restParameters = {
245 @RestParameter(name = "withoperations", description = "Whether the workflow operations should be included in "
246 + "the response", isRequired = false, type = BOOLEAN),
247 @RestParameter(name = "withconfiguration", description = "Whether the workflow configuration should be "
248 + "included in the response", isRequired = false, type = BOOLEAN)
249 },
250 responses = {
251 @RestResponse(description = "The workflow instance is returned.", responseCode = HttpServletResponse.SC_OK),
252 @RestResponse(description = "The user doesn't have the rights to make this request.",
253 responseCode = HttpServletResponse.SC_FORBIDDEN),
254 @RestResponse(description = "The specified workflow instance does not exist.",
255 responseCode = HttpServletResponse.SC_NOT_FOUND)
256 })
257 public Response getWorkflowInstance(@HeaderParam("Accept") String acceptHeader,
258 @PathParam("workflowInstanceId") Long id, @QueryParam("withoperations") boolean withOperations,
259 @QueryParam("withconfiguration") boolean withConfiguration) {
260 WorkflowInstance wi;
261 try {
262 wi = workflowService.getWorkflowById(id);
263 } catch (NotFoundException e) {
264 return ApiResponseBuilder.notFound("Cannot find workflow instance with id '%d'.", id);
265 } catch (UnauthorizedException e) {
266 return Response.status(Response.Status.FORBIDDEN).build();
267 } catch (Exception e) {
268 logger.error("The workflow service was not able to get the workflow instance", e);
269 return ApiResponseBuilder.serverError("Could not retrieve workflow instance, reason: '%s'", e.getMessage());
270 }
271
272 return ApiResponseBuilder.Json.ok(acceptHeader, workflowInstanceToJSON(wi, withOperations, withConfiguration));
273 }
274
275 @PUT
276 @Path("{workflowInstanceId}")
277 @RestQuery(
278 name = "updateworkflowinstance",
279 description = "Creates a workflow instance.",
280 returnDescription = "",
281 pathParameters = {
282 @RestParameter(name = "workflowInstanceId", description = "The workflow instance id", isRequired = true,
283 type = INTEGER)
284 },
285 restParameters = {
286 @RestParameter(name = "configuration", description = "The optional configuration for this workflow",
287 isRequired = false, type = STRING),
288 @RestParameter(name = "state", description = "The optional state transition for this workflow",
289 isRequired = false, type = STRING),
290 @RestParameter(name = "withoperations", description = "Whether the workflow operations should be included in "
291 + "the response", isRequired = false, type = BOOLEAN),
292 @RestParameter(name = "withconfiguration", description = "Whether the workflow configuration should be "
293 + "included in the response", isRequired = false, type = BOOLEAN),
294 },
295 responses = {
296 @RestResponse(description = "The workflow instance is updated.", responseCode = HttpServletResponse.SC_OK),
297 @RestResponse(description = "The request is invalid or inconsistent.",
298 responseCode = HttpServletResponse.SC_BAD_REQUEST),
299 @RestResponse(description = "The user doesn't have the rights to make this request.",
300 responseCode = HttpServletResponse.SC_FORBIDDEN),
301 @RestResponse(description = "The workflow instance could not be found.",
302 responseCode = HttpServletResponse.SC_NOT_FOUND),
303 @RestResponse(description = "The workflow instance cannot transition to this state.",
304 responseCode = HttpServletResponse.SC_CONFLICT)
305 })
306 public Response updateWorkflowInstance(@HeaderParam("Accept") String acceptHeader,
307 @PathParam("workflowInstanceId") Long id, @FormParam("configuration") String configuration,
308 @FormParam("state") String stateStr, @QueryParam("withoperations") boolean withOperations,
309 @QueryParam("withconfiguration") boolean withConfiguration) {
310 try {
311 boolean changed = false;
312 WorkflowInstance wi = workflowService.getWorkflowById(id);
313
314
315 if (isNoneBlank(configuration)) {
316 JSONParser parser = new JSONParser();
317 try {
318 Map<String, String> properties = new HashMap<>((JSONObject) parser.parse(configuration));
319
320
321 wi.getConfigurationKeys().forEach(wi::removeConfiguration);
322
323 properties.forEach(wi::setConfiguration);
324
325 changed = true;
326 } catch (ParseException e) {
327 return RestUtil.R.badRequest("Passed parameter 'configuration' is invalid JSON.");
328 }
329 }
330
331
332
333 if (changed) {
334 workflowService.update(wi);
335 }
336
337
338 if (isNoneBlank(stateStr)) {
339 WorkflowInstance.WorkflowState state;
340 try {
341 state = jsonToEnum(WorkflowInstance.WorkflowState.class, stateStr);
342 } catch (IllegalArgumentException e) {
343 return RestUtil.R.badRequest(String.format("Invalid workflow state '%s'", stateStr));
344 }
345
346 WorkflowInstance.WorkflowState currentState = wi.getState();
347 if (state != currentState) {
348
349
350
351
352
353
354
355
356
357 switch (state) {
358 case PAUSED:
359 workflowService.suspend(wi.getId());
360 break;
361 case STOPPED:
362 workflowService.stop(wi.getId());
363 break;
364 case RUNNING:
365 if (currentState == WorkflowInstance.WorkflowState.INSTANTIATED
366 || currentState == WorkflowInstance.WorkflowState.PAUSED) {
367 workflowService.resume(wi.getId());
368 } else {
369 return RestUtil.R.conflict(
370 String.format("Cannot resume from workflow state '%s'", currentState.toString().toLowerCase()));
371 }
372 break;
373 default:
374 return RestUtil.R.conflict(
375 String.format("Cannot transition state from '%s' to '%s'", currentState.toString().toLowerCase(),
376 stateStr));
377 }
378 }
379 }
380
381 wi = workflowService.getWorkflowById(id);
382 return ApiResponseBuilder.Json.ok(acceptHeader, workflowInstanceToJSON(wi, withOperations, withConfiguration));
383 } catch (NotFoundException e) {
384 return ApiResponseBuilder.notFound("Cannot find workflow instance with id '%d'.", id);
385 } catch (UnauthorizedException e) {
386 return Response.status(Response.Status.FORBIDDEN).build();
387 } catch (Exception e) {
388 logger.error("The workflow service was not able to get the workflow instance", e);
389 return ApiResponseBuilder.serverError("Could not retrieve workflow instance, reason: '%s'", e.getMessage());
390 }
391 }
392
393 @DELETE
394 @Path("{workflowInstanceId}")
395 @RestQuery(
396 name = "deleteworkflowinstance",
397 description = "Deletes a workflow instance.",
398 returnDescription = "",
399 pathParameters = {
400 @RestParameter(name = "workflowInstanceId", description = "The workflow instance id", isRequired = true,
401 type = INTEGER)
402 },
403 responses = {
404 @RestResponse(description = "The workflow instance has been deleted.",
405 responseCode = HttpServletResponse.SC_NO_CONTENT),
406 @RestResponse(description = "The user doesn't have the rights to make this request.",
407 responseCode = HttpServletResponse.SC_FORBIDDEN),
408 @RestResponse(description = "The specified workflow instance does not exist.",
409 responseCode = HttpServletResponse.SC_NOT_FOUND),
410 @RestResponse(description = "The workflow instance cannot be deleted in this state.",
411 responseCode = HttpServletResponse.SC_CONFLICT)
412 })
413 public Response deleteWorkflowInstance(@HeaderParam("Accept") String acceptHeader,
414 @PathParam("workflowInstanceId") Long id) {
415 try {
416 workflowService.remove(id);
417 } catch (WorkflowStateException e) {
418 return RestUtil.R.conflict("Cannot delete workflow instance in this workflow state");
419 } catch (NotFoundException e) {
420 return ApiResponseBuilder.notFound("Cannot find workflow instance with id '%d'.", id);
421 } catch (UnauthorizedException e) {
422 return Response.status(Response.Status.FORBIDDEN).build();
423 } catch (Exception e) {
424 logger.error("Could not delete workflow instances", e);
425 return ApiResponseBuilder.serverError("Could not delete workflow instances, reason: '%s'", e.getMessage());
426 }
427
428 return Response.noContent().build();
429 }
430
431 private JsonObject workflowInstanceToJSON(WorkflowInstance wi, boolean withOperations, boolean withConfiguration) {
432 JsonObject json = new JsonObject();
433
434 json.addProperty("identifier", wi.getId());
435 json.addProperty("title", safeString(wi.getTitle()));
436 json.addProperty("description", safeString(wi.getDescription()));
437 json.addProperty("workflow_definition_identifier", safeString(wi.getTemplate()));
438 json.addProperty("event_identifier", wi.getMediaPackage().getIdentifier().toString());
439 json.addProperty("creator", wi.getCreatorName());
440 json.add("state", enumToJSON(wi.getState()));
441 if (withOperations) {
442 JsonArray operationsArray = new JsonArray();
443 for (WorkflowOperationInstance op : wi.getOperations()) {
444 operationsArray.add(workflowOperationInstanceToJSON(op));
445 }
446 json.add("operations", operationsArray);
447 }
448
449 if (withConfiguration) {
450 JsonObject configObject = new JsonObject();
451 for (String key : wi.getConfigurationKeys()) {
452 String value = wi.getConfiguration(key);
453 configObject.addProperty(key, value);
454 }
455 json.add("configuration", configObject);
456 }
457
458 return json;
459 }
460
461 private JsonObject workflowOperationInstanceToJSON(WorkflowOperationInstance woi) {
462 JsonObject json = new JsonObject();
463 DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneOffset.UTC);
464
465 json.addProperty("identifier", woi.getId());
466 json.addProperty("operation", woi.getTemplate());
467 json.addProperty("description", safeString(woi.getDescription()));
468 json.add("state", enumToJSON(woi.getState()));
469 Long timeInQueue = woi.getTimeInQueue();
470 json.addProperty("time_in_queue", timeInQueue != null ? timeInQueue : 0);
471 json.addProperty("host", safeString(woi.getExecutionHost()));
472 json.addProperty("if", safeString(woi.getExecutionCondition()));
473 json.addProperty("fail_workflow_on_error", woi.isFailOnError());
474 json.addProperty("error_handler_workflow", safeString(woi.getExceptionHandlingWorkflow()));
475 json.addProperty("retry_strategy", safeString(new RetryStrategy.Adapter().marshal(woi.getRetryStrategy())));
476 json.addProperty("max_attempts", woi.getMaxAttempts());
477 json.addProperty("failed_attempts", woi.getFailedAttempts());
478 JsonObject config = new JsonObject();
479 for (String key : woi.getConfigurationKeys()) {
480 config.addProperty(key, woi.getConfiguration(key));
481 }
482 json.add("configuration", config);
483
484 if (woi.getDateStarted() != null) {
485 json.addProperty("start", dateFormatter.format(woi.getDateStarted().toInstant().atZone(ZoneOffset.UTC)));
486 } else {
487 json.addProperty("start", "");
488 }
489
490 if (woi.getDateCompleted() != null) {
491 json.addProperty("completion", dateFormatter.format(woi.getDateCompleted().toInstant().atZone(ZoneOffset.UTC)));
492 } else {
493 json.addProperty("completion", "");
494 }
495
496 return json;
497 }
498
499 private JsonElement enumToJSON(Enum<?> e) {
500 return e == null ? null : new JsonPrimitive(e.toString().toLowerCase());
501 }
502
503 private <T extends Enum<T>> T jsonToEnum(Class<T> enumType, String name) {
504 return Enum.valueOf(enumType, name.toUpperCase());
505 }
506
507 private String getWorkflowUrl(long workflowInstanceId) {
508 return UrlSupport.concat(endpointBaseUrl, Long.toString(workflowInstanceId));
509 }
510 }