WorkflowsEndpoint.java
/*
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License
* at:
*
* http://opensource.org/licenses/ecl2.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*
*/
package org.opencastproject.external.endpoint;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNoneBlank;
import static org.opencastproject.index.service.util.JSONUtils.safeString;
import static org.opencastproject.util.RestUtil.getEndpointUrl;
import static org.opencastproject.util.doc.rest.RestParameter.Type.BOOLEAN;
import static org.opencastproject.util.doc.rest.RestParameter.Type.INTEGER;
import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
import org.opencastproject.elasticsearch.index.objects.event.Event;
import org.opencastproject.external.common.ApiMediaType;
import org.opencastproject.external.common.ApiResponseBuilder;
import org.opencastproject.external.common.ApiVersion;
import org.opencastproject.index.service.api.IndexService;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.rest.RestConstants;
import org.opencastproject.security.api.UnauthorizedException;
import org.opencastproject.systems.OpencastConstants;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.RestUtil;
import org.opencastproject.util.UrlSupport;
import org.opencastproject.util.data.Tuple;
import org.opencastproject.util.doc.rest.RestParameter;
import org.opencastproject.util.doc.rest.RestQuery;
import org.opencastproject.util.doc.rest.RestResponse;
import org.opencastproject.util.doc.rest.RestService;
import org.opencastproject.workflow.api.RetryStrategy;
import org.opencastproject.workflow.api.WorkflowDefinition;
import org.opencastproject.workflow.api.WorkflowInstance;
import org.opencastproject.workflow.api.WorkflowOperationInstance;
import org.opencastproject.workflow.api.WorkflowService;
import org.opencastproject.workflow.api.WorkflowStateException;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.DELETE;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
@Path("/api/workflows")
@Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0, ApiMediaType.VERSION_1_3_0,
ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0, ApiMediaType.VERSION_1_6_0,
ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0, ApiMediaType.VERSION_1_9_0,
ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
@RestService(name = "externalapiworkflowinstances", title = "External API Workflow Instances Service", notes = {},
abstractText = "Provides resources and operations related to the workflow instances")
@Component(
immediate = true,
service = WorkflowsEndpoint.class,
property = {
"service.description=External API - Workflow Instances Endpoint",
"opencast.service.type=org.opencastproject.external.workflows.instances",
"opencast.service.path=/api/workflows"
}
)
@JaxrsResource
public class WorkflowsEndpoint {
/** The logging facility */
private static final Logger logger = LoggerFactory.getLogger(WorkflowsEndpoint.class);
/** Base URL of this endpoint */
protected String endpointBaseUrl;
/* OSGi service references */
private WorkflowService workflowService;
private ElasticsearchIndex elasticsearchIndex;
private IndexService indexService;
/** OSGi DI */
@Reference
public void setWorkflowService(WorkflowService workflowService) {
this.workflowService = workflowService;
}
/** OSGi DI */
@Reference
public void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
this.elasticsearchIndex = elasticsearchIndex;
}
/** OSGi DI */
@Reference
public void setIndexService(IndexService indexService) {
this.indexService = indexService;
}
/**
* OSGi activation method
*/
@Activate
void activate(ComponentContext cc) {
logger.info("Activating External API - Workflow Instances Endpoint");
final Tuple<String, String> endpointUrl = getEndpointUrl(cc, OpencastConstants.EXTERNAL_API_URL_ORG_PROPERTY,
RestConstants.SERVICE_PATH_PROPERTY);
endpointBaseUrl = UrlSupport.concat(endpointUrl.getA(), endpointUrl.getB());
logger.debug("Configured service endpoint is {}", endpointBaseUrl);
}
@POST
@Path("")
@RestQuery(name = "createworkflowinstance", description = "Creates a workflow instance.", returnDescription = "", restParameters = {
@RestParameter(name = "event_identifier", description = "The event identifier this workflow should run against", isRequired = true, type = STRING),
@RestParameter(name = "workflow_definition_identifier", description = "The identifier of the workflow definition to use", isRequired = true, type = STRING),
@RestParameter(name = "configuration", description = "The optional configuration for this workflow", isRequired = false, type = STRING),
@RestParameter(name = "withoperations", description = "Whether the workflow operations should be included in the response", isRequired = false, type = BOOLEAN),
@RestParameter(name = "withconfiguration", description = "Whether the workflow configuration should be included in the response", isRequired = false, type = BOOLEAN), }, responses = {
@RestResponse(description = "A new workflow is created and its identifier is returned in the Location header.", responseCode = HttpServletResponse.SC_CREATED),
@RestResponse(description = "The request is invalid or inconsistent.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
@RestResponse(description = "The event or workflow definition could not be found.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response createWorkflowInstance(@HeaderParam("Accept") String acceptHeader,
@FormParam("event_identifier") String eventId,
@FormParam("workflow_definition_identifier") String workflowDefinitionIdentifier,
@FormParam("configuration") String configuration, @QueryParam("withoperations") boolean withOperations,
@QueryParam("withconfiguration") boolean withConfiguration) {
if (isBlank(eventId)) {
return RestUtil.R.badRequest("Required parameter 'event_identifier' is missing or invalid");
}
if (isBlank(workflowDefinitionIdentifier)) {
return RestUtil.R.badRequest("Required parameter 'workflow_definition_identifier' is missing or invalid");
}
try {
// Media Package
Optional<Event> event = indexService.getEvent(eventId, elasticsearchIndex);
if (event.isEmpty()) {
return ApiResponseBuilder.notFound("Cannot find an event with id '%s'.", eventId);
}
MediaPackage mp = indexService.getEventMediapackage(event.get());
// Workflow definition
WorkflowDefinition wd;
try {
wd = workflowService.getWorkflowDefinitionById(workflowDefinitionIdentifier);
} catch (NotFoundException e) {
return ApiResponseBuilder.notFound("Cannot find a workflow definition with id '%s'.", workflowDefinitionIdentifier);
}
// Configuration
Map<String, String> properties = new HashMap<>();
if (isNoneBlank(configuration)) {
JSONParser parser = new JSONParser();
try {
properties.putAll((JSONObject) parser.parse(configuration));
} catch (ParseException e) {
return RestUtil.R.badRequest("Passed parameter 'configuration' is invalid JSON.");
}
}
// Start workflow
WorkflowInstance wi = workflowService.start(wd, mp, null, properties);
return ApiResponseBuilder.Json.created(acceptHeader, URI.create(getWorkflowUrl(wi.getId())),
workflowInstanceToJSON(wi, withOperations, withConfiguration));
} catch (IllegalStateException e) {
final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
JsonObject json = new JsonObject();
json.addProperty("message", safeString(e.getMessage()));
return ApiResponseBuilder.Json.conflict(requestedVersion, json);
} catch (Exception e) {
logger.error("Could not create workflow instances", e);
return ApiResponseBuilder.serverError("Could not create workflow instances, reason: '%s'", e.getMessage());
}
}
@GET
@Path("{workflowInstanceId}")
@RestQuery(name = "getworkflowinstance", description = "Returns a single workflow instance.", returnDescription = "", pathParameters = {
@RestParameter(name = "workflowInstanceId", description = "The workflow instance id", isRequired = true, type = INTEGER) }, restParameters = {
@RestParameter(name = "withoperations", description = "Whether the workflow operations should be included in the response", isRequired = false, type = BOOLEAN),
@RestParameter(name = "withconfiguration", description = "Whether the workflow configuration should be included in the response", isRequired = false, type = BOOLEAN) }, responses = {
@RestResponse(description = "The workflow instance is returned.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The user doesn't have the rights to make this request.", responseCode = HttpServletResponse.SC_FORBIDDEN),
@RestResponse(description = "The specified workflow instance does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response getWorkflowInstance(@HeaderParam("Accept") String acceptHeader,
@PathParam("workflowInstanceId") Long id, @QueryParam("withoperations") boolean withOperations,
@QueryParam("withconfiguration") boolean withConfiguration) {
WorkflowInstance wi;
try {
wi = workflowService.getWorkflowById(id);
} catch (NotFoundException e) {
return ApiResponseBuilder.notFound("Cannot find workflow instance with id '%d'.", id);
} catch (UnauthorizedException e) {
return Response.status(Response.Status.FORBIDDEN).build();
} catch (Exception e) {
logger.error("The workflow service was not able to get the workflow instance", e);
return ApiResponseBuilder.serverError("Could not retrieve workflow instance, reason: '%s'", e.getMessage());
}
return ApiResponseBuilder.Json.ok(acceptHeader, workflowInstanceToJSON(wi, withOperations, withConfiguration));
}
@PUT
@Path("{workflowInstanceId}")
@RestQuery(name = "updateworkflowinstance", description = "Creates a workflow instance.", returnDescription = "", pathParameters = {
@RestParameter(name = "workflowInstanceId", description = "The workflow instance id", isRequired = true, type = INTEGER) }, restParameters = {
@RestParameter(name = "configuration", description = "The optional configuration for this workflow", isRequired = false, type = STRING),
@RestParameter(name = "state", description = "The optional state transition for this workflow", isRequired = false, type = STRING),
@RestParameter(name = "withoperations", description = "Whether the workflow operations should be included in the response", isRequired = false, type = BOOLEAN),
@RestParameter(name = "withconfiguration", description = "Whether the workflow configuration should be included in the response", isRequired = false, type = BOOLEAN), }, responses = {
@RestResponse(description = "The workflow instance is updated.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The request is invalid or inconsistent.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
@RestResponse(description = "The user doesn't have the rights to make this request.", responseCode = HttpServletResponse.SC_FORBIDDEN),
@RestResponse(description = "The workflow instance could not be found.", responseCode = HttpServletResponse.SC_NOT_FOUND),
@RestResponse(description = "The workflow instance cannot transition to this state.", responseCode = HttpServletResponse.SC_CONFLICT) })
public Response updateWorkflowInstance(@HeaderParam("Accept") String acceptHeader,
@PathParam("workflowInstanceId") Long id, @FormParam("configuration") String configuration,
@FormParam("state") String stateStr, @QueryParam("withoperations") boolean withOperations,
@QueryParam("withconfiguration") boolean withConfiguration) {
try {
boolean changed = false;
WorkflowInstance wi = workflowService.getWorkflowById(id);
// Configuration
if (isNoneBlank(configuration)) {
JSONParser parser = new JSONParser();
try {
Map<String, String> properties = new HashMap<>((JSONObject) parser.parse(configuration));
// Remove old configuration
wi.getConfigurationKeys().forEach(wi::removeConfiguration);
// Add new configuration
properties.forEach(wi::setConfiguration);
changed = true;
} catch (ParseException e) {
return RestUtil.R.badRequest("Passed parameter 'configuration' is invalid JSON.");
}
}
// TODO: does it make sense to change the media package?
if (changed) {
workflowService.update(wi);
}
// State change
if (isNoneBlank(stateStr)) {
WorkflowInstance.WorkflowState state;
try {
state = jsonToEnum(WorkflowInstance.WorkflowState.class, stateStr);
} catch (IllegalArgumentException e) {
return RestUtil.R.badRequest(String.format("Invalid workflow state '%s'", stateStr));
}
WorkflowInstance.WorkflowState currentState = wi.getState();
if (state != currentState) {
// Allowed transitions:
//
// instantiated -> paused, stopped, running
// running -> paused, stopped
// failing -> paused, stopped
// paused -> paused, stopped, running
// succeeded -> paused, stopped
// stopped -> paused, stopped
// failed -> paused, stopped
switch (state) {
case PAUSED:
workflowService.suspend(wi.getId());
break;
case STOPPED:
workflowService.stop(wi.getId());
break;
case RUNNING:
if (currentState == WorkflowInstance.WorkflowState.INSTANTIATED
|| currentState == WorkflowInstance.WorkflowState.PAUSED) {
workflowService.resume(wi.getId());
} else {
return RestUtil.R.conflict(
String.format("Cannot resume from workflow state '%s'", currentState.toString().toLowerCase()));
}
break;
default:
return RestUtil.R.conflict(
String.format("Cannot transition state from '%s' to '%s'", currentState.toString().toLowerCase(),
stateStr));
}
}
}
wi = workflowService.getWorkflowById(id);
return ApiResponseBuilder.Json.ok(acceptHeader, workflowInstanceToJSON(wi, withOperations, withConfiguration));
} catch (NotFoundException e) {
return ApiResponseBuilder.notFound("Cannot find workflow instance with id '%d'.", id);
} catch (UnauthorizedException e) {
return Response.status(Response.Status.FORBIDDEN).build();
} catch (Exception e) {
logger.error("The workflow service was not able to get the workflow instance", e);
return ApiResponseBuilder.serverError("Could not retrieve workflow instance, reason: '%s'", e.getMessage());
}
}
@DELETE
@Path("{workflowInstanceId}")
@RestQuery(name = "deleteworkflowinstance", description = "Deletes a workflow instance.", returnDescription = "", pathParameters = {
@RestParameter(name = "workflowInstanceId", description = "The workflow instance id", isRequired = true, type = INTEGER) }, responses = {
@RestResponse(description = "The workflow instance has been deleted.", responseCode = HttpServletResponse.SC_NO_CONTENT),
@RestResponse(description = "The user doesn't have the rights to make this request.", responseCode = HttpServletResponse.SC_FORBIDDEN),
@RestResponse(description = "The specified workflow instance does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND),
@RestResponse(description = "The workflow instance cannot be deleted in this state.", responseCode = HttpServletResponse.SC_CONFLICT) })
public Response deleteWorkflowInstance(@HeaderParam("Accept") String acceptHeader,
@PathParam("workflowInstanceId") Long id) {
try {
workflowService.remove(id);
} catch (WorkflowStateException e) {
return RestUtil.R.conflict("Cannot delete workflow instance in this workflow state");
} catch (NotFoundException e) {
return ApiResponseBuilder.notFound("Cannot find workflow instance with id '%d'.", id);
} catch (UnauthorizedException e) {
return Response.status(Response.Status.FORBIDDEN).build();
} catch (Exception e) {
logger.error("Could not delete workflow instances", e);
return ApiResponseBuilder.serverError("Could not delete workflow instances, reason: '%s'", e.getMessage());
}
return Response.noContent().build();
}
private JsonObject workflowInstanceToJSON(WorkflowInstance wi, boolean withOperations, boolean withConfiguration) {
JsonObject json = new JsonObject();
json.addProperty("identifier", wi.getId());
json.addProperty("title", safeString(wi.getTitle()));
json.addProperty("description", safeString(wi.getDescription()));
json.addProperty("workflow_definition_identifier", safeString(wi.getTemplate()));
json.addProperty("event_identifier", wi.getMediaPackage().getIdentifier().toString());
json.addProperty("creator", wi.getCreatorName());
json.add("state", enumToJSON(wi.getState()));
if (withOperations) {
JsonArray operationsArray = new JsonArray();
for (WorkflowOperationInstance op : wi.getOperations()) {
operationsArray.add(workflowOperationInstanceToJSON(op));
}
json.add("operations", operationsArray);
}
if (withConfiguration) {
JsonObject configObject = new JsonObject();
for (String key : wi.getConfigurationKeys()) {
String value = wi.getConfiguration(key);
configObject.addProperty(key, value);
}
json.add("configuration", configObject);
}
return json;
}
private JsonObject workflowOperationInstanceToJSON(WorkflowOperationInstance woi) {
JsonObject json = new JsonObject();
DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneOffset.UTC);
json.addProperty("identifier", woi.getId());
json.addProperty("operation", woi.getTemplate());
json.addProperty("description", safeString(woi.getDescription()));
json.add("state", enumToJSON(woi.getState()));
Long timeInQueue = woi.getTimeInQueue();
json.addProperty("time_in_queue", timeInQueue != null ? timeInQueue : 0);
json.addProperty("host", safeString(woi.getExecutionHost()));
json.addProperty("if", safeString(woi.getExecutionCondition()));
json.addProperty("fail_workflow_on_error", woi.isFailOnError());
json.addProperty("error_handler_workflow", safeString(woi.getExceptionHandlingWorkflow()));
json.addProperty("retry_strategy", safeString(new RetryStrategy.Adapter().marshal(woi.getRetryStrategy())));
json.addProperty("max_attempts", woi.getMaxAttempts());
json.addProperty("failed_attempts", woi.getFailedAttempts());
JsonObject config = new JsonObject();
for (String key : woi.getConfigurationKeys()) {
config.addProperty(key, woi.getConfiguration(key));
}
json.add("configuration", config);
if (woi.getDateStarted() != null) {
json.addProperty("start", dateFormatter.format(woi.getDateStarted().toInstant().atZone(ZoneOffset.UTC)));
} else {
json.addProperty("start", "");
}
if (woi.getDateCompleted() != null) {
json.addProperty("completion", dateFormatter.format(woi.getDateCompleted().toInstant().atZone(ZoneOffset.UTC)));
} else {
json.addProperty("completion", "");
}
return json;
}
private JsonElement enumToJSON(Enum<?> e) {
return e == null ? null : new JsonPrimitive(e.toString().toLowerCase());
}
private <T extends Enum<T>> T jsonToEnum(Class<T> enumType, String name) {
return Enum.valueOf(enumType, name.toUpperCase());
}
private String getWorkflowUrl(long workflowInstanceId) {
return UrlSupport.concat(endpointBaseUrl, Long.toString(workflowInstanceId));
}
}