View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
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   /** The logging facility */
111   private static final Logger logger = LoggerFactory.getLogger(WorkflowsEndpoint.class);
112 
113   /** Base URL of this endpoint */
114   protected String endpointBaseUrl;
115 
116   /* OSGi service references */
117   private WorkflowService workflowService;
118   private ElasticsearchIndex elasticsearchIndex;
119   private IndexService indexService;
120 
121   /** OSGi DI */
122   @Reference
123   public void setWorkflowService(WorkflowService workflowService) {
124     this.workflowService = workflowService;
125   }
126 
127   /** OSGi DI */
128   @Reference
129   public void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
130     this.elasticsearchIndex = elasticsearchIndex;
131   }
132 
133   /** OSGi DI */
134   @Reference
135   public void setIndexService(IndexService indexService) {
136     this.indexService = indexService;
137   }
138 
139   /**
140    * OSGi activation method
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       // Media Package
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       // Workflow definition
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       // Configuration
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       // Start workflow
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       // Configuration
315       if (isNoneBlank(configuration)) {
316         JSONParser parser = new JSONParser();
317         try {
318           Map<String, String> properties = new HashMap<>((JSONObject) parser.parse(configuration));
319 
320           // Remove old configuration
321           wi.getConfigurationKeys().forEach(wi::removeConfiguration);
322           // Add new configuration
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       // TODO: does it make sense to change the media package?
332 
333       if (changed) {
334         workflowService.update(wi);
335       }
336 
337       // State change
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           // Allowed transitions:
349           //
350           //   instantiated -> paused, stopped, running
351           //   running      -> paused, stopped
352           //   failing      -> paused, stopped
353           //   paused       -> paused, stopped, running
354           //   succeeded    -> paused, stopped
355           //   stopped      -> paused, stopped
356           //   failed       -> paused, stopped
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 }