LifeCycleManagementEndpoint.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.trimToNull;
import static org.opencastproject.index.service.util.JSONUtils.safeString;
import static org.opencastproject.util.DateTimeSupport.toUTC;
import static org.opencastproject.util.RestUtil.getEndpointUrl;
import static org.opencastproject.util.doc.rest.RestParameter.Type.INTEGER;
import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
import static org.opencastproject.util.doc.rest.RestParameter.Type.TEXT;

import org.opencastproject.external.common.ApiMediaType;
import org.opencastproject.external.common.ApiResponseBuilder;
import org.opencastproject.index.service.api.IndexService;
import org.opencastproject.lifecyclemanagement.api.Action;
import org.opencastproject.lifecyclemanagement.api.LifeCyclePolicy;
import org.opencastproject.lifecyclemanagement.api.LifeCyclePolicyAccessControlEntry;
import org.opencastproject.lifecyclemanagement.api.LifeCycleService;
import org.opencastproject.lifecyclemanagement.api.LifeCycleServiceException;
import org.opencastproject.lifecyclemanagement.api.StartWorkflowParameters;
import org.opencastproject.lifecyclemanagement.api.TargetType;
import org.opencastproject.lifecyclemanagement.api.Timing;
import org.opencastproject.lifecyclemanagement.impl.LifeCyclePolicyAccessControlEntryImpl;
import org.opencastproject.lifecyclemanagement.impl.LifeCyclePolicyImpl;
import org.opencastproject.metadata.dublincore.EncodingSchemeUtils;
import org.opencastproject.opensearch.api.SearchIndexException;
import org.opencastproject.opensearch.index.objects.event.EventSearchQueryField;
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.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.util.requests.SortCriterion;

import com.cronutils.mapper.CronMapper;
import com.cronutils.model.Cron;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.parser.CronParser;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;

import org.apache.commons.lang3.StringUtils;
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.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

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/lifecyclemanagement")
@Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_12_0 })
@RestService(
    name = "externalapilifecyclemanagement",
    title = "External API LifeCycle Management Service",
    notes = {},
    abstractText = "Manage life cycle policies"
)
@Component(
    immediate = true,
    service = LifeCycleManagementEndpoint.class,
    property = {
        "service.description=External API - LifeCycle Management Endpoint",
        "opencast.service.type=org.opencastproject.external.lifecyclemanagement",
        "opencast.service.path=/api/lifecyclemanagement"
    }
)
@JaxrsResource
public class LifeCycleManagementEndpoint {

  /** The logging facility */
  private static final Logger logger = LoggerFactory.getLogger(LifeCycleManagementEndpoint.class);
  private static final Gson gson = new Gson();

  /** Base URL of this endpoint */
  protected String endpointBaseUrl;

  /** The lifecycle service */
  private LifeCycleService service;

  protected IndexService indexService;

  @Reference
  public void setLifeCycleService(LifeCycleService lifeCycleService) {
    this.service = lifeCycleService;
  }

  @Reference
  public void setIndexService(IndexService indexService) {
    this.indexService = indexService;
  }

  private final String actionParametersExampleJSON = "{\n"
      + "  workflowId: noop,\n"
      + "  workflowParameters: {\n"
      + "    straightToPublishing: true\n"
      + "  }\n"
      + "}";

  /** OSGi activation method */
  @Activate
  void activate(ComponentContext cc) {
    logger.info("Activating External API - LifeCycle Management 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());
  }

  @GET
  @Path("policies/{id}")
  @RestQuery(
      name = "lifeCyclePolicy",
      description = "Get a lifecycle policy.",
      returnDescription = "A lifecycle policy as JSON",
      pathParameters = {
          @RestParameter(name = "id", isRequired = true, description = "The lifecycle policy identifier",
              type = STRING),
      },
      responses = {
          @RestResponse(description = "Returns the lifecycle policy.", responseCode = HttpServletResponse.SC_OK),
          @RestResponse(description = "The specified lifecycle policy instance does not exist.",
              responseCode = HttpServletResponse.SC_NOT_FOUND),
          @RestResponse(description = "The user doesn't have the rights to make this request.",
              responseCode = HttpServletResponse.SC_FORBIDDEN),
          @RestResponse(description = "The request is invalid or inconsistent.",
              responseCode = HttpServletResponse.SC_BAD_REQUEST),
      })
  public Response getPolicy(
      @HeaderParam("Accept") String acceptHeader,
      @PathParam("id") String id) {
    try {
      LifeCyclePolicy policy = service.getLifeCyclePolicyById(id);

      if (!StringUtils.isEmpty(policy.getCronTrigger())) {
        CronParser unixParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ));
        Cron cron = unixParser.parse(policy.getCronTrigger());
        CronMapper cronMapper = CronMapper.fromQuartzToUnix();
        Cron cron4jCron = cronMapper.map(cron);
        policy.setCronTrigger(cron4jCron.asString());
      }

      return ApiResponseBuilder.Json.ok(acceptHeader, policyToJson(policy));
    } catch (NotFoundException e) {
      return ApiResponseBuilder.notFound("Cannot find playlist instance with id '%s'.", id);
    } catch (UnauthorizedException e) {
      return Response.status(Response.Status.FORBIDDEN).build();
    } catch (LifeCycleServiceException e) {
      return Response.serverError().build();
    }
  }

  @GET
  @Path("policies")
  @RestQuery(
      name = "policies",
      description = "Get policies. Policies that you do not have read access to will not show up.",
      returnDescription = "A JSON object containing an array.",
      restParameters = {
          @RestParameter(name = "limit", isRequired = false, type = INTEGER,
              description = "The maximum number of results to return for a single request.", defaultValue = "100"),
          @RestParameter(name = "offset", isRequired = false, type = INTEGER,
              description = "The index of the first result to return."),
          @RestParameter(name = "sort", isRequired = false, type = STRING,
              description = "Sort the results based upon a sorting criteria. A criteria is specified as a pair such as:"
                + "<Sort Name>:ASC or <Sort Name>:DESC. Adding the suffix ASC or DESC sets the order as ascending or"
                + "descending order and is mandatory. Sort Name is case sensitive. Supported Sort Names are 'title'"
              , defaultValue = "title:ASC"),
      },
      responses = {
          @RestResponse(description = "Returns the playlist.", responseCode = HttpServletResponse.SC_OK),
          @RestResponse(description = "The request is invalid or inconsistent.",
              responseCode = HttpServletResponse.SC_BAD_REQUEST),
      })
  public Response getPoliciesAsJson(
      @HeaderParam("Accept") String acceptHeader,
      @QueryParam("limit") int limit,
      @QueryParam("offset") int offset,
      @QueryParam("sort") String sort) throws LifeCycleServiceException {
    if (offset < 0) {
      return Response.status(Response.Status.BAD_REQUEST).build();
    }

    if (limit < 1) {
      return Response.status(Response.Status.BAD_REQUEST).build();
    }

    SortCriterion sortCriterion = new SortCriterion("", SortCriterion.Order.None);
    Optional<String> optSort = Optional.ofNullable(trimToNull(sort));
    if (optSort.isPresent()) {
      sortCriterion = SortCriterion.parse(optSort.get());

      switch (sortCriterion.getFieldName()) {
        case "title":
          break;
        default:
          logger.info("Unknown sort criteria {}", sortCriterion.getFieldName());
          return Response.serverError().status(Response.Status.BAD_REQUEST).build();
      }
    }
    List<LifeCyclePolicy> policies = service.getLifeCyclePolicies(limit, offset, sortCriterion);

    long total = service.getLifeCyclePoliciesTotal();

    JsonArray policiesJsonArray = new JsonArray();
    for (LifeCyclePolicy policy : policies) {
      policiesJsonArray.add(policyToJson(policy));
    }

    JsonObject resultJson = new JsonObject();
    resultJson.addProperty("total", total);
    resultJson.addProperty("limit", limit);
    resultJson.addProperty("offset", offset);
    resultJson.add("results", policiesJsonArray);

    // Return the response
    return ApiResponseBuilder.Json.ok(acceptHeader, resultJson);
  }

  @GET
  @Path("policies/actions")
  @RestQuery(
      name = "actions",
      description = "Get lifecycle policy actions.",
      returnDescription = "Lifecycle policy actions as JSON array",
      responses = {
          @RestResponse(description = "Returns the lifecycle policy actions.",
              responseCode = HttpServletResponse.SC_OK),
      })
  public Response getActions(@HeaderParam("Accept") String acceptHeader) {
    JsonArray actionsJson = new JsonArray();
    for (Action action : Action.values()) {
      actionsJson.add(new JsonPrimitive(action.toString()));
    }

    return ApiResponseBuilder.Json.ok(acceptHeader, actionsJson);
  }

  @GET
  @Path("policies/targettypes")
  @RestQuery(
      name = "targettypes",
      description = "Get lifecycle policy targettypes.",
      returnDescription = "Lifecycle policy targettypes as JSON array",
      responses = {
          @RestResponse(description = "Returns the lifecycle policy targettypes.",
              responseCode = HttpServletResponse.SC_OK),
      })
  public Response getTargetTypes(@HeaderParam("Accept") String acceptHeader) {
    JsonArray targetTypesJson = new JsonArray();
    for (TargetType type : TargetType.values()) {
      targetTypesJson.add(new JsonPrimitive(type.toString()));
    }

    return ApiResponseBuilder.Json.ok(acceptHeader, targetTypesJson);
  }

  @GET
  @Path("policies/timings")
  @RestQuery(
      name = "timings",
      description = "Get lifecycle policy timings.",
      returnDescription = "Lifecycle policy timings as JSON array",
      responses = {
          @RestResponse(description = "Returns the lifecycle policy timings.",
              responseCode = HttpServletResponse.SC_OK),
      })
  public Response getTimings(@HeaderParam("Accept") String acceptHeader) {
    JsonArray timingsJson = new JsonArray();
    for (Timing timing : Timing.values()) {
      timingsJson.add(new JsonPrimitive(timing.toString()));
    }

    return ApiResponseBuilder.Json.ok(acceptHeader, timingsJson);
  }

  @GET
  @Path("policiesForEvent/{eventId}")
  @RestQuery(
      name = "policiesForEvent",
      description = "Get active lifecycle policies that would target the given event were they to be executed now.",
      returnDescription = "Lifecycle policies",
      pathParameters = {
          @RestParameter(name = "eventId", isRequired = true, description = "The lifecycle policy identifier",
              type = STRING),
          @RestParameter(name = "limit", description = "The maximum number of results to return for a single request.",
              isRequired = false, type = RestParameter.Type.INTEGER),
          @RestParameter(name = "offset", description = "The index of the first result to return.", isRequired = false,
              type = RestParameter.Type.INTEGER)
      },
      responses = {
          @RestResponse(description = "Returns the lifecycle policies.", responseCode = HttpServletResponse.SC_OK),
      })
  public Response getPoliciesForEvent(@HeaderParam("Accept") String acceptHeader, @PathParam("eventId") String eventId,
      @QueryParam("limit") int limit, @QueryParam("offset") int offset)
          throws SearchIndexException, NotFoundException, LifeCycleServiceException {
    // Set default limit if necessary
    if (limit < 1) {
      limit = 100;
    }

    // Create a filter with the event id
    var eventFilter = new EventSearchQueryField(eventId);

    // Get policies
    var lifeCyclePolicies = service.getActiveLifeCyclePolicies();

    // Run filters
    List<LifeCyclePolicy> policiesForEvent = new ArrayList<>();
    for (var policy : lifeCyclePolicies) {
      var targetFilters = policy.getTargetFilters();
      String catalogFlavor = indexService.getCommonEventCatalogUIAdapter().getFlavor().toString();
      targetFilters
          .computeIfAbsent(catalogFlavor, k -> new HashMap<>())
          .put("uid", eventFilter);
      var events = service.filterForEvents(targetFilters, limit, offset);
      if (!events.isEmpty()) {
        policiesForEvent.add(policy);
      }
    }

    JsonArray policiesJsonArray = new JsonArray();
    for (LifeCyclePolicy policy : policiesForEvent) {
      policiesJsonArray.add(policyToJson(policy));
    }
    return ApiResponseBuilder.Json.ok(acceptHeader, policiesJsonArray);
  }

  @POST
  @Path("policies")
  @RestQuery(
      name = "create",
      description = "Creates a lifecycle policy.",
      returnDescription = "The created lifecycle policy.",
      restParameters = {
          @RestParameter(name = "title", isRequired = true, description = "Policy Title", type = STRING),
          @RestParameter(name = "targetType", description = "EVENT, SERIES", isRequired = true, type = STRING,
              defaultValue = "EVENT"),
          @RestParameter(name = "action", description = "START_WORKFLOW", isRequired = true, type = STRING,
              defaultValue = "START_WORKFLOW"),
          @RestParameter(name = "actionParameters", description = "Depend entirely on the chosen action. JSON. To find "
              + "how to structure your JSON check the documentation.", isRequired = false, type = TEXT,
              defaultValue = actionParametersExampleJSON),
          @RestParameter(name = "actionDate", description = "Required if timing is SPECIFIC_DATE. "
              + "E.g. 2023-11-30T16:16:47Z", isRequired = false, type = STRING),
          @RestParameter(name = "cronTrigger", description = "Required if timing is REPEATING. "
              + "https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html",
              isRequired = false, type = STRING),
          @RestParameter(name = "timing", description = "SPECIFIC_DATE, REPEATING, ALWAYS", isRequired = true,
              type = STRING, defaultValue = "SPECIFIC_DATE"),
          @RestParameter(name = "targetFilters", isRequired = false, description = "Used to select applicable "
              + "entities. JSON. To find how to structure your JSON check the documentation.", type = TEXT),
          @RestParameter(name = "accessControlEntries", description = "Which user have what permissions on this "
              + "policy. JSON. To find how to structure your JSON check the documentation.", isRequired = false,
              type = TEXT, defaultValue = ""),
      },
      responses = {
          @RestResponse(description = "Policy created.", responseCode = HttpServletResponse.SC_CREATED),
          @RestResponse(description = "The user doesn't have the rights to make this request.",
              responseCode = HttpServletResponse.SC_FORBIDDEN),
          @RestResponse(description = "The request is invalid or inconsistent.",
              responseCode = HttpServletResponse.SC_BAD_REQUEST),
      })
  public Response createAsJson(
      @HeaderParam("Accept") String acceptHeader,
      @FormParam("title") String title,
      @FormParam("targetType") String targetType,
      @FormParam("action") String action,
      @FormParam("actionParameters") String actionParameters,
      @FormParam("actionDate") String actionDate,
      @FormParam("cronTrigger") String cronTrigger,
      @FormParam("timing") String timing,
      @FormParam("targetFilters") String targetFilters,
      @FormParam("accessControlEntries") String accessControlEntries
  ) {
    try {
      // Check if required fields are present
      if (title == null || title.isEmpty()) {
        return Response.status(Response.Status.BAD_REQUEST).build();
      }
      if (targetType == null || targetType.isEmpty()) {
        return Response.status(Response.Status.BAD_REQUEST).build();
      }
      if (action == null || action.isEmpty()) {
        return Response.status(Response.Status.BAD_REQUEST).build();
      }
      if (timing == null || timing.isEmpty()) {
        return Response.status(Response.Status.BAD_REQUEST).build();
      }

      // Check if conditionally required fields are present
      if (Timing.valueOf(timing) == Timing.SPECIFIC_DATE) {
        if (actionDate == null || actionDate.isEmpty()) {
          return Response.status(Response.Status.BAD_REQUEST).build();
        }
      }
      if (Timing.valueOf(timing) == Timing.REPEATING) {
        if (cronTrigger == null || cronTrigger.isEmpty()) {
          return Response.status(Response.Status.BAD_REQUEST).build();
        }
      }

      // Check if cron string is valid
      if (cronTrigger != null && !cronTrigger.isEmpty()) {
        CronParser unixParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        Cron cron = unixParser.parse(cronTrigger);
        CronMapper cronMapper = CronMapper.fromUnixToQuartz();
        Cron cron4jCron = cronMapper.map(cron);
        cronTrigger = cron4jCron.asString();

        if (!org.quartz.CronExpression.isValidExpression(cronTrigger)) {
          return Response.status(Response.Status.BAD_REQUEST).build();
        }
      }

      // Check if action parameters are well formed
      try {
        if (Action.valueOf(action) == Action.START_WORKFLOW) {
          StartWorkflowParameters actionParametersParsed = gson.fromJson(actionParameters,
              StartWorkflowParameters.class);
          if (actionParametersParsed.getWorkflowId() == null) {
            return Response.status(Response.Status.BAD_REQUEST).build();
          }
        }
      } catch (JsonSyntaxException e) {
        return Response.status(Response.Status.BAD_REQUEST).build();
      }

      // Check if ACL is well formed
      List<LifeCyclePolicyAccessControlEntryImpl> accessControlEntriesParsed = new ArrayList<>();
      if (accessControlEntries != null && !accessControlEntries.isEmpty()) {
        try {
          accessControlEntriesParsed = gson.fromJson(accessControlEntries,
            new TypeToken<List<LifeCyclePolicyAccessControlEntryImpl>>() { }.getType());
          if (accessControlEntriesParsed == null) {
            accessControlEntriesParsed = new ArrayList<>();
          }
        } catch (JsonSyntaxException e) {
          return Response.status(Response.Status.BAD_REQUEST).build();
        }
      }

      // Convert filters
      Map<String, Map<String, EventSearchQueryField<String>>> filtersMap = new HashMap<>();
      if (targetFilters != null && !targetFilters.isEmpty()) {
        try {
          filtersMap = gson.fromJson(targetFilters,
            new TypeToken<Map<String, Map<String, EventSearchQueryField<String>>> >() { }.getType());
          if (filtersMap == null) {
            filtersMap = new HashMap<>();
          }
        } catch (JsonSyntaxException e) {
          return Response.status(Response.Status.BAD_REQUEST).build();
        }
      }

      LifeCyclePolicy policy = new LifeCyclePolicyImpl(
          title,
          TargetType.valueOf(targetType),
          Action.valueOf(action),
          actionParameters,
          EncodingSchemeUtils.decodeDate(actionDate),
          cronTrigger,
          Timing.valueOf(timing),
          filtersMap,
          accessControlEntriesParsed
      );

      service.createLifeCyclePolicy(policy);
      return Response.status(Response.Status.CREATED).build();
    } catch (UnauthorizedException e) {
      return Response.status(Response.Status.FORBIDDEN).build();
    } catch (IllegalArgumentException e) {
      return Response.status(Response.Status.BAD_REQUEST).build();
    } catch (LifeCycleServiceException e) {
      return Response.serverError().build();
    }
  }

  @PUT
  @Path("policies/{id}")
  @RestQuery(
      name = "update",
      description = "Updates a lifecycle policy.",
      returnDescription = "The updated lifecycle policy.",
      pathParameters = {
          @RestParameter(name = "id", isRequired = true, description = "Policy identifier", type = STRING)
      },
      restParameters = {
          @RestParameter(name = "title", isRequired = false, description = "Policy Title", type = STRING),
          @RestParameter(name = "targetType", description = "EVENT, SERIES", isRequired = false, type = STRING),
          @RestParameter(name = "action", description = "START_WORKFLOW", isRequired = false, type = STRING),
          @RestParameter(name = "actionParameters", description = "Depend entirely on the chosen action. JSON. To "
              + "find how to structure your JSON check the documentation.", isRequired = false, type = TEXT),
          @RestParameter(name = "actionDate", description = "Required if timing is SPECIFIC_DATE. "
              + "E.g. 2023-11-30T16:16:47Z", isRequired = false, type = STRING),
          @RestParameter(name = "cronTrigger", description = "Required if timing is REPEATING. "
              + "https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html",
              isRequired = false, type = STRING),
          @RestParameter(name = "timing", description = "SPECIFIC_DATE, REPEATING, ALWAYS", isRequired = false,
              type = STRING),
          @RestParameter(name = "targetFilters", isRequired = false, description = "The filter(s) used to select "
              + "applicable entities. Format: 'filter1:value1,filter2:value2'", type = TEXT),
          @RestParameter(name = "accessControlEntries", description = "JSON. To find how to structure your JSON check "
              + "the documentation.", isRequired = false, type = TEXT),
      },
      responses = {
          @RestResponse(description = "Policy updated.", responseCode = HttpServletResponse.SC_OK),
          @RestResponse(description = "The user doesn't have the rights to make this request.",
              responseCode = HttpServletResponse.SC_FORBIDDEN),
          @RestResponse(description = "The request is invalid or inconsistent. Will also occur if the policy was "
              + "created from configuration.",
              responseCode = HttpServletResponse.SC_BAD_REQUEST),
      })
  public Response updateAsJson(
      @HeaderParam("Accept") String acceptHeader,
      @PathParam("id") String id,
      @FormParam("title") String title,
      @FormParam("targetType") String targetType,
      @FormParam("action") String action,
      @FormParam("actionParameters") String actionParameters,
      @FormParam("actionDate") String actionDate,
      @FormParam("cronTrigger") String cronTrigger,
      @FormParam("timing") String timing,
      @FormParam("targetFilters") String targetFilters,
      @FormParam("accessControlEntries") String accessControlEntries
  ) {
    try {
      LifeCyclePolicy policy = service.getLifeCyclePolicyById(id);

      if (policy.isCreatedFromConfig()) {
        return Response.status(Response.Status.BAD_REQUEST).build();
      }

      List<String> actionParametersList = new ArrayList<>();
      if (StringUtils.isNotBlank(actionParameters)) {
        for (String actionParameter : StringUtils.split(actionParameters, ",")) {
          actionParametersList.add(actionParameter);
        }
      }

      if (title != null && !title.isEmpty()) {
        policy.setTitle(title);
      }
      if (targetType != null && !targetType.isEmpty()) {
        policy.setTargetType(TargetType.valueOf(targetType));
      }
      if (action != null && !action.isEmpty()) {
        policy.setAction(Action.valueOf(action));
      }
      if (actionParametersList != null && !actionParametersList.isEmpty()) {
        // Check if action parameters are well formed
        try {
          if (Action.valueOf(action) == Action.START_WORKFLOW) {
            StartWorkflowParameters actionParametersParsed = gson.fromJson(actionParameters,
                StartWorkflowParameters.class);
            if (actionParametersParsed.getWorkflowId() == null) {
              return Response.status(Response.Status.BAD_REQUEST).build();
            }
          }
          policy.setActionParameters(actionParameters);
        } catch (JsonSyntaxException e) {
          return Response.status(Response.Status.BAD_REQUEST).build();
        }
      }
      if (actionDate != null && !actionDate.isEmpty()) {
        policy.setActionDate(EncodingSchemeUtils.decodeDate(actionDate));
      }
      if (cronTrigger != null && !cronTrigger.isEmpty()) {
        CronParser unixParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
        Cron cron = unixParser.parse(cronTrigger);
        CronMapper cronMapper = CronMapper.fromUnixToQuartz();
        Cron cron4jCron = cronMapper.map(cron);
        cronTrigger = cron4jCron.asString();

        policy.setCronTrigger(cronTrigger);
      }
      if (timing != null && !timing.isEmpty()) {
        policy.setTiming(Timing.valueOf(timing));
      }
      if (targetFilters != null && !targetFilters.isEmpty()) {
        Map<String, Map<String, EventSearchQueryField<String>>>  filtersMap;
        if (targetFilters != null && !targetFilters.isEmpty()) {
          try {
            filtersMap = gson.fromJson(targetFilters,
              new TypeToken<Map<String, Map<String, EventSearchQueryField<String>>>>() { }.getType());
            if (filtersMap == null) {
              filtersMap = new HashMap<>();
            }
            policy.setTargetFilters(filtersMap);
          } catch (JsonSyntaxException e) {
            return Response.status(Response.Status.BAD_REQUEST).build();
          }
        }
      }
      if (accessControlEntries != null && !accessControlEntries.isEmpty()) {
        // Check if ACL is well formed
        try {
          List<LifeCyclePolicyAccessControlEntry> accessControlEntriesParsed = gson.fromJson(accessControlEntries,
              new TypeToken<List<LifeCyclePolicyAccessControlEntryImpl>>() { }.getType());
          if (accessControlEntriesParsed == null) {
            accessControlEntriesParsed = new ArrayList<>();
          }
          policy.setAccessControlEntries(accessControlEntriesParsed);
        } catch (JsonSyntaxException e) {
          return Response.status(Response.Status.BAD_REQUEST).build();
        }
      }

      service.updateLifeCyclePolicy(policy);
      return Response.status(Response.Status.OK).build();
    } catch (NotFoundException e) {
      return ApiResponseBuilder.notFound("Cannot find playlist instance with id '%s'.", id);
    } catch (UnauthorizedException e) {
      return Response.status(Response.Status.FORBIDDEN).build();
    } catch (IllegalArgumentException e) {
      return Response.status(Response.Status.BAD_REQUEST).build();
    } catch (LifeCycleServiceException e) {
      return Response.serverError().build();
    }
  }

  @DELETE
  @Path("policies/{id}")
  @RestQuery(
      name = "remove",
      description = "Removes a lifecycle policy.",
      returnDescription = "The removed lifecycle policy.",
      pathParameters = {
          @RestParameter(name = "id", isRequired = true, description = "Policy identifier", type = STRING)
      },
      responses = {
          @RestResponse(responseCode = HttpServletResponse.SC_OK, description = "Policy removed."),
          @RestResponse(responseCode = HttpServletResponse.SC_NOT_FOUND, description = "No lifecycle policy with that "
              + "identifier exists."),
          @RestResponse(responseCode = HttpServletResponse.SC_UNAUTHORIZED, description = "Not authorized to perform "
              + "this action")
      })
  public Response remove(
      @HeaderParam("Accept") String acceptHeader,
      @PathParam("id") String id) {
    try {
      service.deleteLifeCyclePolicy(id);
      return Response.status(Response.Status.OK).build();
    } catch (NotFoundException e) {
      return ApiResponseBuilder.notFound("Cannot find policy instance with id '%s'.", id);
    } catch (UnauthorizedException e) {
      return Response.status(Response.Status.FORBIDDEN).build();
    } catch (LifeCycleServiceException e) {
      return Response.serverError().build();
    }
  }

  private JsonObject policyToJson(LifeCyclePolicy policy) {
    JsonObject json = new JsonObject();

    json.addProperty("id", policy.getId());
    json.addProperty("title", safeString(policy.getTitle()));
    json.addProperty("targetType", enumToJson(policy.getTargetType()));
    json.addProperty("action", enumToJson(policy.getAction()));

    if (policy.getAction() == Action.START_WORKFLOW) {
      json.addProperty("actionParameters", startWorkflowParametersToJson(policy.getActionParameters()).toString());
    } else {
      json.addProperty("actionParameters", safeString(policy.getActionParameters()));
    }

    json.addProperty("actionDate", policy.getActionDate() != null ? toUTC(policy.getActionDate().getTime()) : "");
    json.addProperty("cronTrigger", safeString(policy.getCronTrigger()));
    json.addProperty("timing", enumToJson(policy.getTiming()));
    json.addProperty("isActive", policy.isActive());
    json.addProperty("isCreatedFromConfig", policy.isCreatedFromConfig());

    json.addProperty("targetFilters", gson.toJson(policy.getTargetFilters()));


    JsonArray accessControlEntries = new JsonArray();
    for (LifeCyclePolicyAccessControlEntry entry : policy.getAccessControlEntries()) {
      accessControlEntries.add(policyAccessControlEntryToJson(entry));
    }
    json.add("accessControlEntries", accessControlEntries);

    return json;
  }

  private JsonObject policyAccessControlEntryToJson(LifeCyclePolicyAccessControlEntry entry) {
    JsonObject json = new JsonObject();
    json.addProperty("id", entry.getId());
    json.addProperty("allow", entry.isAllow());
    json.addProperty("role", entry.getRole());
    json.addProperty("action", entry.getAction());
    return json;
  }

  private JsonObject eventSearchQueryFieldToJson(EventSearchQueryField field) {
    JsonObject json = new JsonObject();
    json.addProperty("value", safeString(field.getValue()));
    json.addProperty("type", safeString(field.getType()));
    json.addProperty("must", field.isMust());
    return json;
  }

  private JsonObject startWorkflowParametersToJson(String jsonStr) {
    StartWorkflowParameters parameters = gson.fromJson(jsonStr, StartWorkflowParameters.class);

    JsonObject json = new JsonObject();
    json.addProperty("workflowId", parameters.getWorkflowId());

    String jsonString = "{" + parameters.getWorkflowParameters().entrySet().stream()
        .map(entry -> {
          String key = "\"" + entry.getKey() + "\"";
          String value = entry.getValue();

          if ("true".equals(value) || "false".equals(value) || value.matches("-?\\d+(\\.\\d+)?")) {
            return key + ":" + value;
          }

          String escaped = value.toString()
              .replace("\\", "\\\\")
              .replace("\"", "\\\"");
          return key + ":\"" + escaped + "\"";
        })
        .collect(Collectors.joining(",")) + "}";
    json.addProperty("workflowParameters", safeString(jsonString));

    return json;
  }

  private String enumToJson(Enum<?> e) {
    return e != null ? e.toString() : null;
  }
}