/**
* 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 com.entwinemedia.fn.Stream.$;
import static com.entwinemedia.fn.data.json.Jsons.BLANK;
import static com.entwinemedia.fn.data.json.Jsons.arr;
import static com.entwinemedia.fn.data.json.Jsons.f;
import static com.entwinemedia.fn.data.json.Jsons.obj;
import static com.entwinemedia.fn.data.json.Jsons.v;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.trimToNull;
import static org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace;
import static org.opencastproject.external.common.ApiVersion.VERSION_1_0_0;
import static org.opencastproject.index.service.util.RestUtils.okJson;
import static org.opencastproject.util.DateTimeSupport.toUTC;
import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
import org.opencastproject.external.common.ApiResponses;
import org.opencastproject.external.common.ApiVersion;
import org.opencastproject.external.impl.index.ExternalIndex;
import org.opencastproject.external.util.AclUtils;
import org.opencastproject.external.util.ExternalMetadataUtils;
import org.opencastproject.index.service.api.IndexService;
import org.opencastproject.index.service.catalog.adapter.MetadataList;
import org.opencastproject.index.service.catalog.adapter.MetadataUtils;
import org.opencastproject.index.service.exception.IndexServiceException;
import org.opencastproject.index.service.impl.index.event.EventIndexSchema;
import org.opencastproject.index.service.impl.index.series.Series;
import org.opencastproject.index.service.impl.index.series.SeriesIndexSchema;
import org.opencastproject.index.service.impl.index.series.SeriesSearchQuery;
import org.opencastproject.index.service.util.RequestUtils;
import org.opencastproject.index.service.util.RestUtils;
import org.opencastproject.matterhorn.search.SearchIndexException;
import org.opencastproject.matterhorn.search.SearchResult;
import org.opencastproject.matterhorn.search.SearchResultItem;
import org.opencastproject.matterhorn.search.SortCriterion;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.metadata.dublincore.DublinCore;
import org.opencastproject.metadata.dublincore.MetadataCollection;
import org.opencastproject.metadata.dublincore.MetadataField;
import org.opencastproject.metadata.dublincore.SeriesCatalogUIAdapter;
import org.opencastproject.rest.RestConstants;
import org.opencastproject.security.api.AccessControlEntry;
import org.opencastproject.security.api.AccessControlList;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.UnauthorizedException;
import org.opencastproject.series.api.SeriesException;
import org.opencastproject.series.api.SeriesService;
import org.opencastproject.systems.MatterhornConstants;
import org.opencastproject.util.DateTimeSupport;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.RestUtil;
import org.opencastproject.util.RestUtil.R;
import org.opencastproject.util.UrlSupport;
import org.opencastproject.util.data.Option;
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 com.entwinemedia.fn.Fn;
import com.entwinemedia.fn.data.Opt;
import com.entwinemedia.fn.data.json.Field;
import com.entwinemedia.fn.data.json.JValue;
import com.entwinemedia.fn.data.json.Jsons;
import com.entwinemedia.fn.data.json.Jsons.Functions;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.json.simple.JSONArray;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeMap;
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.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
@Path("/")
@RestService(name = "externalapiseries", title = "External API Series Service", notes = "", abstractText = "Provides resources and operations related to the series")
public class SeriesEndpoint {
private static final int CREATED_BY_UI_ORDER = 9;
private static final int DEFAULT_LIMIT = 100;
private static final Logger logger = LoggerFactory.getLogger(SeriesEndpoint.class);
/** The json parser */
private static final JSONParser parser = new JSONParser();
/** Default server URL */
protected String serverUrl = "http://localhost:8080";
/** Service url */
protected String serviceUrl = null;
/* OSGi service references */
private ExternalIndex externalIndex;
private IndexService indexService;
private SecurityService securityService;
private SeriesService seriesService;
/** OSGi DI */
void setExternalIndex(ExternalIndex externalIndex) {
this.externalIndex = externalIndex;
}
/** OSGi DI */
void setIndexService(IndexService indexService) {
this.indexService = indexService;
}
/** OSGi DI */
void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
/** OSGi DI */
void setSeriesService(SeriesService seriesService) {
this.seriesService = seriesService;
}
/** OSGi activation method */
void activate(ComponentContext cc) {
if (cc == null) {
this.serverUrl = "http://localhost:8080";
} else {
String ccServerUrl = cc.getBundleContext().getProperty(MatterhornConstants.EXTERNAL_API_URL_ORG_PROPERTY);
logger.debug("Configured server url is {}", ccServerUrl);
if (ccServerUrl == null)
this.serverUrl = "http://localhost:8080";
else {
this.serverUrl = ccServerUrl;
}
}
serviceUrl = (String) cc.getProperties().get(RestConstants.SERVICE_PATH_PROPERTY);
logger.info("Activated External API - Series Endpoint");
}
@GET
@Path("")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "getseries", description = "Returns a list of series.", returnDescription = "", restParameters = {
@RestParameter(name = "filter", isRequired = false, description = "A comma seperated list of filters to limit the results with. A filter is the filter's name followed by a colon \":\" and then the value to filter with so it is the form <Filter Name>:<Value to Filter With>.", type = STRING),
@RestParameter(name = "sort", description = "Sort the results based upon a list of comma seperated sorting criteria. In the comma seperated list each type of sorting 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.", isRequired = false, 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 = "Number of results to skip based on the limit. 0 is the first set of results up to the limit, 1 is the second set of results after the first limit, 2 is third set of results after skipping the first two sets of results etc.", isRequired = false, type = RestParameter.Type.INTEGER) }, reponses = {
@RestResponse(description = "A (potentially empty) list of series is returned.", responseCode = HttpServletResponse.SC_OK) })
public Response getSeriesList(@HeaderParam("Accept") String acceptHeader, @QueryParam("filter") String filter,
@QueryParam("sort") String sort, @QueryParam("order") String order, @QueryParam("offset") int offset,
@QueryParam("limit") int limit) throws UnauthorizedException {
try {
SeriesSearchQuery query = new SeriesSearchQuery(securityService.getOrganization().getId(),
securityService.getUser());
Option<String> optSort = Option.option(trimToNull(sort));
if (offset > 0) {
query.withOffset(offset);
}
// If limit is 0, we set the default limit
query.withLimit(limit < 1 ? DEFAULT_LIMIT : limit);
// Parse the filters
if (StringUtils.isNotBlank(filter)) {
for (String f : filter.split(",")) {
String[] filterTuple = f.split(":");
if (filterTuple.length != 2) {
logger.info("No value for filter {} in filters list: {}", filterTuple[0], filter);
continue;
}
String name = filterTuple[0];
String value = filterTuple[1];
if ("managedAcl".equals(name)) {
query.withAccessPolicy(value);
} else if ("contributors".equals(name)) {
query.withContributor(value);
} else if ("CreationDate".equals(name)) {
if (name.split("/").length == 2) {
try {
Tuple<Date, Date> fromAndToCreationRange = getFromAndToCreationRange(name.split("/")[0],
name.split("/")[1]);
query.withCreatedFrom(fromAndToCreationRange.getA());
query.withCreatedTo(fromAndToCreationRange.getB());
} catch (IllegalArgumentException e) {
return RestUtil.R.badRequest(e.getMessage());
}
}
query.withCreator(value);
} else if ("Creator".equals(name)) {
query.withCreator(value);
} else if ("textFilter".equals(name)) {
query.withText("*" + value + "*");
} else if ("language".equals(name)) {
query.withLanguage(value);
} else if ("license".equals(name)) {
query.withLicense(value);
} else if ("organizers".equals(name)) {
query.withOrganizer(value);
} else if ("subject".equals(name)) {
query.withSubject(value);
} else if ("title".equals(name)) {
query.withTitle(value);
}
}
}
if (optSort.isSome()) {
Set<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
for (SortCriterion criterion : sortCriteria) {
switch (criterion.getFieldName()) {
case SeriesIndexSchema.TITLE:
query.sortByTitle(criterion.getOrder());
break;
case SeriesIndexSchema.CONTRIBUTORS:
query.sortByContributors(criterion.getOrder());
break;
case SeriesIndexSchema.CREATOR:
query.sortByOrganizers(criterion.getOrder());
break;
case EventIndexSchema.CREATED:
query.sortByCreatedDateTime(criterion.getOrder());
break;
default:
logger.info("Unknown filter criteria {}", criterion.getFieldName());
return Response.status(SC_BAD_REQUEST).build();
}
}
}
logger.trace("Using Query: " + query.toString());
SearchResult<Series> result = externalIndex.getByQuery(query);
return ApiResponses.Json.ok(VERSION_1_0_0, arr($(result.getItems()).map(new Fn<SearchResultItem<Series>, JValue>() {
@Override
public JValue apply(SearchResultItem<Series> a) {
final Series s = a.getSource();
JValue subjects;
if (s.getSubject() == null) {
subjects = arr();
} else {
subjects = arr(splitSubjectIntoArray(s.getSubject()));
}
return obj(f("identifier", v(s.getIdentifier())), f("title", v(s.getTitle())), f("creator", v(s.getCreator())),
f("created", v(toUTC(s.getCreatedDateTime().getTime()))), f("subjects", subjects),
f("contributors", arr($(s.getContributors()).map(Functions.stringToJValue))),
f("organizers", arr($(s.getOrganizers()).map(Functions.stringToJValue))),
f("publishers", arr($(s.getPublishers()).map(Functions.stringToJValue))));
}
}).toList()));
} catch (Exception e) {
logger.warn("Could not perform search query: {}", ExceptionUtils.getStackTrace(e));
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
}
@GET
@Path("{seriesId}")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "getseries", description = "Returns a single series.", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, reponses = {
@RestResponse(description = "The series is returned.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response getSeries(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id)
throws Exception {
for (final Series s : indexService.getSeries(id, externalIndex)) {
JValue subjects;
if (s.getSubject() == null) {
subjects = arr();
} else {
subjects = arr(splitSubjectIntoArray(s.getSubject()));
}
return ApiResponses.Json.ok(VERSION_1_0_0, obj(
f("identifier", v(s.getIdentifier())), f("title", v(s.getTitle())),
f("description", v(s.getDescription())), f("creator", v(s.getCreator())), f("subjects", subjects),
f("organization", v(s.getOrganization())), f("created", v(toUTC(s.getCreatedDateTime().getTime()))),
f("contributors", arr($(s.getContributors()).map(Functions.stringToJValue))),
f("organizers", arr($(s.getOrganizers()).map(Functions.stringToJValue))),
f("publishers", arr($(s.getPublishers()).map(Functions.stringToJValue))),
f("opt_out", v(s.isOptedOut()))));
}
return ApiResponses.notFound("Cannot find an series with id '%s'.", id);
}
private List<JValue> splitSubjectIntoArray(final String subject) {
return com.entwinemedia.fn.Stream.$(subject.split(",")).map(new Fn<String, JValue>() {
@Override
public JValue apply(String a) {
return v(a.trim());
}
}).toList();
}
@GET
@Path("{seriesId}/metadata")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "getseriesmetadata", description = "Returns a series' metadata of all types or returns a series' metadata collection of the given type when the query string parameter type is specified. For each metadata catalog there is a unique property called the flavor such as dublincore/series so the type in this example would be 'dublincore/series'", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, restParameters = {
@RestParameter(name = "type", isRequired = false, description = "The type of metadata to return", type = STRING) }, reponses = {
@RestResponse(description = "The series' metadata are returned.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response getSeriesMetadata(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id,
@QueryParam("type") String type) throws Exception {
if (StringUtils.trimToNull(type) == null) {
return getAllMetadata(id);
} else {
return getMetadataByType(id, type);
}
}
private Response getAllMetadata(String id) throws SearchIndexException {
Opt<Series> optSeries = indexService.getSeries(id, externalIndex);
if (optSeries.isNone())
return ApiResponses.notFound("Cannot find a series with id '%s'.", id);
MetadataList metadataList = new MetadataList();
List<SeriesCatalogUIAdapter> catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
for (SeriesCatalogUIAdapter adapter : catalogUIAdapters) {
final Opt<MetadataCollection> optSeriesMetadata = adapter.getFields(id);
if (optSeriesMetadata.isSome()) {
metadataList.add(adapter.getFlavor(), adapter.getUITitle(), optSeriesMetadata.get());
}
}
MetadataCollection collection = getSeriesMetadata(optSeries.get());
ExternalMetadataUtils.changeSubjectToSubjects(collection);
metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), collection);
return okJson(metadataList.toJSON());
}
private Response getMetadataByType(String id, String type) throws SearchIndexException {
Opt<Series> optSeries = indexService.getSeries(id, externalIndex);
if (optSeries.isNone())
return ApiResponses.notFound("Cannot find a series with id '%s'.", id);
// Try the main catalog first as we load it from the index.
if (typeMatchesSeriesCatalogUIAdapter(type, indexService.getCommonSeriesCatalogUIAdapter())) {
MetadataCollection collection = getSeriesMetadata(optSeries.get());
ExternalMetadataUtils.changeSubjectToSubjects(collection);
return ApiResponses.Json.ok(ApiVersion.VERSION_1_0_0, collection.toJSON());
}
// Try the other catalogs
List<SeriesCatalogUIAdapter> catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
for (SeriesCatalogUIAdapter adapter : catalogUIAdapters) {
if (typeMatchesSeriesCatalogUIAdapter(type, adapter)) {
final Opt<MetadataCollection> optSeriesMetadata = adapter.getFields(id);
if (optSeriesMetadata.isSome()) {
return ApiResponses.Json.ok(ApiVersion.VERSION_1_0_0, optSeriesMetadata.get().toJSON());
}
}
}
return ApiResponses.notFound("Cannot find a catalog with type '%s' for series with id '%s'.", type, id);
}
/**
* Loads the metadata for the given series
*
* @param series
* the source {@link Series}
* @return a {@link MetadataCollection} instance with all the series metadata
*/
@SuppressWarnings("unchecked")
private MetadataCollection getSeriesMetadata(Series series) {
MetadataCollection metadata = indexService.getCommonSeriesCatalogUIAdapter().getRawFields();
MetadataField<?> title = metadata.getOutputFields().get(DublinCore.PROPERTY_TITLE.getLocalName());
metadata.removeField(title);
MetadataField<String> newTitle = MetadataUtils.copyMetadataField(title);
newTitle.setValue(series.getTitle());
metadata.addField(newTitle);
MetadataField<?> subject = metadata.getOutputFields().get(DublinCore.PROPERTY_SUBJECT.getLocalName());
metadata.removeField(subject);
MetadataField<String> newSubject = MetadataUtils.copyMetadataField(subject);
newSubject.setValue(series.getSubject());
metadata.addField(newSubject);
MetadataField<?> description = metadata.getOutputFields().get(DublinCore.PROPERTY_DESCRIPTION.getLocalName());
metadata.removeField(description);
MetadataField<String> newDescription = MetadataUtils.copyMetadataField(description);
newDescription.setValue(series.getDescription());
metadata.addField(newDescription);
MetadataField<?> language = metadata.getOutputFields().get(DublinCore.PROPERTY_LANGUAGE.getLocalName());
metadata.removeField(language);
MetadataField<String> newLanguage = MetadataUtils.copyMetadataField(language);
newLanguage.setValue(series.getLanguage());
metadata.addField(newLanguage);
MetadataField<?> rightsHolder = metadata.getOutputFields().get(DublinCore.PROPERTY_RIGHTS_HOLDER.getLocalName());
metadata.removeField(rightsHolder);
MetadataField<String> newRightsHolder = MetadataUtils.copyMetadataField(rightsHolder);
newRightsHolder.setValue(series.getRightsHolder());
metadata.addField(newRightsHolder);
MetadataField<?> license = metadata.getOutputFields().get(DublinCore.PROPERTY_LICENSE.getLocalName());
metadata.removeField(license);
MetadataField<String> newLicense = MetadataUtils.copyMetadataField(license);
newLicense.setValue(series.getLicense());
metadata.addField(newLicense);
MetadataField<?> organizers = metadata.getOutputFields().get(DublinCore.PROPERTY_CREATOR.getLocalName());
metadata.removeField(organizers);
MetadataField<String> newOrganizers = MetadataUtils.copyMetadataField(organizers);
newOrganizers.setValue(StringUtils.join(series.getOrganizers(), ", "));
metadata.addField(newOrganizers);
MetadataField<?> contributors = metadata.getOutputFields().get(DublinCore.PROPERTY_CONTRIBUTOR.getLocalName());
metadata.removeField(contributors);
MetadataField<String> newContributors = MetadataUtils.copyMetadataField(contributors);
newContributors.setValue(StringUtils.join(series.getContributors(), ", "));
metadata.addField(newContributors);
MetadataField<?> publishers = metadata.getOutputFields().get(DublinCore.PROPERTY_PUBLISHER.getLocalName());
metadata.removeField(publishers);
MetadataField<String> newPublishers = MetadataUtils.copyMetadataField(publishers);
newPublishers.setValue(StringUtils.join(series.getPublishers(), ", "));
metadata.addField(newPublishers);
// Admin UI only field
MetadataField<String> createdBy = MetadataField.createTextMetadataField("createdBy", Opt.<String> none(),
"EVENTS.SERIES.DETAILS.METADATA.CREATED_BY", true, false, Opt.<Map<String, String>> none(),
Opt.<String> none(), Opt.some(CREATED_BY_UI_ORDER), Opt.<String> none());
createdBy.setValue(series.getCreator());
metadata.addField(createdBy);
MetadataField<?> uid = metadata.getOutputFields().get(DublinCore.PROPERTY_IDENTIFIER.getLocalName());
metadata.removeField(uid);
MetadataField<String> newUID = MetadataUtils.copyMetadataField(uid);
newUID.setValue(series.getIdentifier());
metadata.addField(newUID);
ExternalMetadataUtils.removeCollectionList(metadata);
return metadata;
}
/**
* Checks if a flavor type matches a series catalog's flavor type.
*
* @param type
* The flavor type to compare against the catalog's flavor
* @param catalog
* The catalog to check if it matches the flavor.
* @return True if it matches.
*/
private boolean typeMatchesSeriesCatalogUIAdapter(String type, SeriesCatalogUIAdapter catalog) {
if (StringUtils.trimToNull(type) == null) {
return false;
}
MediaPackageElementFlavor catalogFlavor = MediaPackageElementFlavor.parseFlavor(catalog.getFlavor());
try {
MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(type);
return flavor.equals(catalogFlavor);
} catch (IllegalArgumentException e) {
return false;
}
}
private Opt<MediaPackageElementFlavor> getFlavor(String flavorString) {
try {
MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(flavorString);
return Opt.some(flavor);
} catch (IllegalArgumentException e) {
return Opt.none();
}
}
@PUT
@Path("{seriesId}/metadata")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "updateseriesmetadata", description = "Update a series' metadata of the given type. For a metadata catalog there is the flavor such as 'dublincore/series' and this is the unique type.", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, restParameters = {
@RestParameter(name = "type", isRequired = true, description = "The type of metadata to update", type = STRING),
@RestParameter(name = "metadata", description = "Series metadata as Form param", isRequired = true, type = STRING) }, reponses = {
@RestResponse(description = "The series' metadata have been updated.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The request is invalid or inconsistent.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response updateSeriesMetadata(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id,
@QueryParam("type") String type, @FormParam("metadata") String metadataJSON) throws Exception {
if (StringUtils.trimToNull(metadataJSON) == null) {
return RestUtil.R.badRequest("Unable to update metadata for series as the metadata provided is empty.");
}
Map<String, String> updatedFields;
try {
updatedFields = RequestUtils.getKeyValueMap(metadataJSON);
} catch (ParseException e) {
logger.debug("Unable to update series '{}' with metadata type '{}' and content '{}' because: {}",
new Object[] { id, type, metadataJSON, ExceptionUtils.getStackTrace(e) });
return RestUtil.R.badRequest(String.format("Unable to parse metadata fields as json from '%s' because '%s'",
metadataJSON, ExceptionUtils.getStackTrace(e)));
} catch (IllegalArgumentException e) {
return RestUtil.R.badRequest(e.getMessage());
}
if (updatedFields == null || updatedFields.size() == 0) {
return RestUtil.R.badRequest(
String.format("Unable to parse metadata fields as json from '%s' because there were no fields to update.",
metadataJSON));
}
Opt<MetadataCollection> optCollection = Opt.none();
SeriesCatalogUIAdapter adapter = null;
Opt<Series> optSeries = indexService.getSeries(id, externalIndex);
if (optSeries.isNone())
return ApiResponses.notFound("Cannot find a series with id '%s'.", id);
MetadataList metadataList = new MetadataList();
// Try the main catalog first as we load it from the index.
if (typeMatchesSeriesCatalogUIAdapter(type, indexService.getCommonSeriesCatalogUIAdapter())) {
optCollection = Opt.some(getSeriesMetadata(optSeries.get()));
adapter = indexService.getCommonSeriesCatalogUIAdapter();
} else {
metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), getSeriesMetadata(optSeries.get()));
}
// Try the other catalogs
List<SeriesCatalogUIAdapter> catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
if (catalogUIAdapters.size() > 0) {
for (SeriesCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
if (typeMatchesSeriesCatalogUIAdapter(type, catalogUIAdapter)) {
optCollection = catalogUIAdapter.getFields(id);
adapter = catalogUIAdapter;
} else {
Opt<MetadataCollection> current = catalogUIAdapter.getFields(id);
if (current.isSome()) {
metadataList.add(catalogUIAdapter, current.get());
}
}
}
}
if (optCollection.isNone()) {
return ApiResponses.notFound("Cannot find a catalog with type '%s' for series with id '%s'.", type, id);
}
MetadataCollection collection = optCollection.get();
for (String key : updatedFields.keySet()) {
MetadataField<?> field = collection.getOutputFields().get(key);
if (field == null) {
return ApiResponses.notFound(
"Cannot find a metadata field with id '%s' from event with id '%s' and the metadata type '%s'.", key,
id, type);
} else if (field.isRequired() && StringUtils.isBlank(updatedFields.get(key))) {
return R.badRequest(String.format(
"The series metadata field with id '%s' and the metadata type '%s' is required and can not be empty!.",
key, type));
}
collection.removeField(field);
collection.addField(MetadataField.copyMetadataFieldWithValue(field, updatedFields.get(key)));
}
metadataList.add(adapter, collection);
indexService.updateAllSeriesMetadata(id, metadataList, externalIndex);
return ApiResponses.Json.ok(ApiVersion.VERSION_1_0_0, "");
}
@DELETE
@Path("{seriesId}/metadata")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "deleteseriesmetadata", description = "Deletes a series' metadata catalog of the given type. All fields and values of that catalog will be deleted.", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, restParameters = {
@RestParameter(name = "type", isRequired = true, description = "The type of metadata to delete", type = STRING) }, reponses = {
@RestResponse(description = "The metadata have been deleted.", responseCode = HttpServletResponse.SC_NO_CONTENT),
@RestResponse(description = "The main metadata catalog dublincore/series cannot be deleted as it has mandatory fields.", responseCode = HttpServletResponse.SC_FORBIDDEN),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response deleteSeriesMetadataByType(@HeaderParam("Accept") String acceptHeader,
@PathParam("seriesId") String id, @QueryParam("type") String type) throws Exception {
if (StringUtils.trimToNull(type) == null) {
return RestUtil.R
.badRequest(String.format("A type of catalog needs to be specified for series '%s' to delete it.", id));
}
Opt<MediaPackageElementFlavor> flavor = getFlavor(type);
if (flavor.isNone()) {
return RestUtil.R.badRequest(
String.format("Unable to parse flavor '%s' it should look something like dublincore/series.", type));
}
if (typeMatchesSeriesCatalogUIAdapter(type, indexService.getCommonSeriesCatalogUIAdapter())) {
return Response
.status(Status.FORBIDDEN).entity(String
.format("Unable to delete mandatory metadata catalog with type '%s' for series '%s'", type, id))
.build();
}
Opt<Series> optSeries = indexService.getSeries(id, externalIndex);
if (optSeries.isNone())
return ApiResponses.notFound("Cannot find a series with id '%s'.", id);
try {
indexService.removeCatalogByFlavor(optSeries.get(), MediaPackageElementFlavor.parseFlavor(type));
} catch (NotFoundException e) {
return ApiResponses.notFound(e.getMessage());
}
return ApiResponses.Json.noContent(ApiVersion.VERSION_1_0_0);
}
@GET
@Path("{seriesId}/acl")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "getseriesacl", description = "Returns a series' access policy.", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, reponses = {
@RestResponse(description = "The series' access policy is returned.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response getSeriesAcl(@PathParam("seriesId") String id) throws Exception {
for (final Series series : indexService.getSeries(id, externalIndex)) {
// The ACL is stored as JSON string in the index. Parse it and extract the part we want to have in the API.
JSONObject acl = (JSONObject) parser.parse(series.getAccessPolicy());
return ApiResponses.Json.ok(VERSION_1_0_0, ((JSONArray) ((JSONObject) acl.get("acl")).get("ace")).toJSONString());
}
return ApiResponses.notFound("Cannot find an series with id '%s'.", id);
}
@GET
@Path("{seriesId}/properties")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "getseriesproperties", description = "Returns a series' properties", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, reponses = {
@RestResponse(description = "The series' properties are returned.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response getSeriesProperties(@PathParam("seriesId") String id) throws Exception {
if (indexService.getSeries(id, externalIndex).isSome()) {
final Map<String, String> properties = seriesService.getSeriesProperties(id);
return ApiResponses.Json.ok(VERSION_1_0_0, obj($(properties.entrySet()).map(new Fn<Entry<String, String>, Field>() {
@Override
public Field apply(Entry<String, String> a) {
return f(a.getKey(), v(a.getValue(), Jsons.BLANK));
}
}).toList()));
} else {
return ApiResponses.notFound("Cannot find an series with id '%s'.", id);
}
}
@DELETE
@Path("{seriesId}")
@RestQuery(name = "deleteseries", description = "Deletes a series.", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, reponses = {
@RestResponse(description = "The series has been deleted.", responseCode = HttpServletResponse.SC_NO_CONTENT),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response deleteSeries(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id)
throws NotFoundException {
try {
indexService.removeSeries(id);
return Response.noContent().build();
} catch (NotFoundException e) {
return ApiResponses.notFound("Cannot find a series with id '%s'.", id);
} catch (Exception e) {
logger.error("Unable to delete the series '{}' due to: {}", id, ExceptionUtils.getStackTrace(e));
return Response.serverError().build();
}
}
@PUT
@Path("{seriesId}")
@RestQuery(name = "updateallseriesmetadata", description = "Update all series metadata.", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, restParameters = {
@RestParameter(name = "metadata", description = "Series metadata as Form param", isRequired = true, type = STRING) }, reponses = {
@RestResponse(description = "The series' metadata have been updated.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The request is invalid or inconsistent.", responseCode = HttpServletResponse.SC_BAD_REQUEST),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response updateSeriesMetadata(@PathParam("seriesId") String seriesID,
@FormParam("metadata") String metadataJSON)
throws UnauthorizedException, NotFoundException, SearchIndexException {
try {
MetadataList metadataList = indexService.updateAllSeriesMetadata(seriesID, metadataJSON, externalIndex);
return okJson(metadataList.toJSON());
} catch (IllegalArgumentException e) {
logger.debug("Unable to update series '{}' with metadata '{}' because: {}",
new Object[] { seriesID, metadataJSON, ExceptionUtils.getStackTrace(e) });
return RestUtil.R.badRequest(e.getMessage());
} catch (IndexServiceException e) {
logger.error("Unable to update series '{}' with metadata '{}' because: {}",
new Object[] { seriesID, metadataJSON, ExceptionUtils.getStackTrace(e) });
return RestUtil.R.serverError();
}
}
@POST
@Path("")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "createseries", description = "Creates a series.", returnDescription = "", restParameters = {
@RestParameter(name = "metadata", isRequired = true, description = "Series metadata", type = STRING),
@RestParameter(name = "acl", description = "A collection of roles with their possible action", isRequired = false, type = STRING),
@RestParameter(name = "theme", description = "The theme ID to be applied to the series", isRequired = false, type = STRING) }, reponses = {
@RestResponse(description = "A new series 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 user doesn't have the rights to create the series.", responseCode = HttpServletResponse.SC_UNAUTHORIZED) })
public Response createNewSeries(@HeaderParam("Accept") String acceptHeader,
@FormParam("metadata") String metadataParam, @FormParam("acl") String aclParam,
@FormParam("theme") String themeIdParam) throws UnauthorizedException, NotFoundException {
if (isBlank(metadataParam))
return R.badRequest("Required parameter 'metadata' is missing or invalid");
MetadataList metadataList;
try {
metadataList = deserializeMetadataList(metadataParam);
} catch (ParseException e) {
logger.debug("Unable to parse series metadata '{}' because: {}", metadataParam, ExceptionUtils.getStackTrace(e));
return R.badRequest(String.format("Unable to parse metadata because '%s'", e.toString()));
} catch (NotFoundException e) {
// One of the metadata fields could not be found in the catalogs or one of the catalogs cannot be found.
return R.badRequest(e.getMessage());
} catch (IllegalArgumentException e) {
logger.debug("Unable to create series with metadata '{}' because: {}", metadataParam,
ExceptionUtils.getStackTrace(e));
return R.badRequest(e.getMessage());
}
Map<String, String> options = new TreeMap<>();
Opt<Long> optThemeId = Opt.none();
if (StringUtils.trimToNull(themeIdParam) != null) {
try {
Long themeId = Long.parseLong(themeIdParam);
optThemeId = Opt.some(themeId);
} catch (NumberFormatException e) {
return R.badRequest(String.format("Unable to parse the theme id '%s' into a number", themeIdParam));
}
}
AccessControlList acl;
try {
acl = AclUtils.deserializeJsonToAcl(aclParam, false);
} catch (ParseException e) {
logger.debug("Unable to parse acl '{}' because: '{}'", aclParam, ExceptionUtils.getStackTrace(e));
return R.badRequest(String.format("Unable to parse acl '%s' because '%s'", aclParam, e.getMessage()));
} catch (IllegalArgumentException e) {
logger.debug("Unable to create new series with acl '{}' because: '{}'", aclParam,
ExceptionUtils.getStackTrace(e));
return R.badRequest(e.getMessage());
}
try {
String seriesId = indexService.createSeries(metadataList, options, Opt.some(acl), optThemeId);
return ApiResponses.Json.created(VERSION_1_0_0, URI.create(getSeriesUrl(seriesId)),
obj(f("identifier", v(seriesId, BLANK))));
} catch (IndexServiceException e) {
logger.error("Unable to create series with metadata '{}', acl '{}', theme '{}' because: ",
new Object[] { metadataParam, aclParam, themeIdParam, ExceptionUtils.getStackTrace(e) });
throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
}
}
/**
* Change the simplified fields of key values provided to the external api into a {@link MetadataList}.
*
* @param json
* The json string that contains an array of metadata field lists for the different catalogs.
* @return A {@link MetadataList} with the fields populated with the values provided.
* @throws ParseException
* Thrown if unable to parse the json string.
* @throws NotFoundException
* Thrown if unable to find the catalog or field that the json refers to.
*/
protected MetadataList deserializeMetadataList(String json) throws ParseException, NotFoundException {
MetadataList metadataList = new MetadataList();
JSONArray jsonCatalogs = (JSONArray) parser.parse(json);
for (int i = 0; i < jsonCatalogs.size(); i++) {
JSONObject catalog = (JSONObject) jsonCatalogs.get(i);
if (catalog.get("flavor") == null || StringUtils.isBlank(catalog.get("flavor").toString())) {
throw new IllegalArgumentException(
"Unable to create new series as no flavor was given for one of the metadata collections");
}
String flavorString = catalog.get("flavor").toString();
MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(flavorString);
MetadataCollection collection = null;
SeriesCatalogUIAdapter adapter = null;
for (SeriesCatalogUIAdapter seriesCatalogUIAdapter : indexService.getSeriesCatalogUIAdapters()) {
MediaPackageElementFlavor catalogFlavor = MediaPackageElementFlavor
.parseFlavor(seriesCatalogUIAdapter.getFlavor());
if (catalogFlavor.equals(flavor)) {
adapter = seriesCatalogUIAdapter;
collection = seriesCatalogUIAdapter.getRawFields();
}
}
if (collection == null) {
throw new IllegalArgumentException(
String.format("Unable to find an SeriesCatalogUIAdapter with Flavor '%s'", flavorString));
}
String fieldsJson = catalog.get("fields").toString();
if (StringUtils.trimToNull(fieldsJson) != null) {
Map<String, String> fields = RequestUtils.getKeyValueMap(fieldsJson);
for (String key : fields.keySet()) {
if ("subjects".equals(key)) {
MetadataField<?> field = collection.getOutputFields().get("subject");
if (field == null) {
throw new NotFoundException(String.format(
"Cannot find a metadata field with id '%s' from Catalog with Flavor '%s'.", key, flavorString));
}
collection.removeField(field);
try {
JSONArray subjects = (JSONArray) parser.parse(fields.get(key));
collection.addField(
MetadataField.copyMetadataFieldWithValue(field, StringUtils.join(subjects.iterator(), ",")));
} catch (ParseException e) {
throw new IllegalArgumentException(
String.format("Unable to parse the 'subjects' metadata array field because: %s", e.toString()));
}
} else {
MetadataField<?> field = collection.getOutputFields().get(key);
if (field == null) {
throw new NotFoundException(String.format(
"Cannot find a metadata field with id '%s' from Catalog with Flavor '%s'.", key, flavorString));
}
collection.removeField(field);
collection.addField(MetadataField.copyMetadataFieldWithValue(field, fields.get(key)));
}
}
}
metadataList.add(adapter, collection);
}
return metadataList;
}
@PUT
@Path("{seriesId}/acl")
@Produces({ "application/json", "application/v1.0.0+json" })
@RestQuery(name = "updateseriesacl", description = "Updates a series' access policy.", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, restParameters = {
@RestParameter(name = "acl", isRequired = true, description = "Access policy", type = STRING) }, reponses = {
@RestResponse(description = "The access control list for the specified series is updated.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response updateSeriesAcl(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String seriesID,
@FormParam("acl") String aclJson) throws NotFoundException, SeriesException, UnauthorizedException {
if (isBlank(aclJson))
return R.badRequest("Missing form parameter 'acl'");
JSONArray acl;
try {
acl = (JSONArray) parser.parse(aclJson);
} catch (ParseException e) {
logger.debug("Could not parse ACL ({}): {}", aclJson, getStackTrace(e));
return R.badRequest("Could not parse ACL");
}
List<AccessControlEntry> accessControlEntries = $(acl.toArray()).map(new Fn<Object, AccessControlEntry>() {
@Override
public AccessControlEntry apply(Object a) {
JSONObject ace = (JSONObject) a;
return new AccessControlEntry((String) ace.get("role"), (String) ace.get("action"), (boolean) ace.get("allow"));
}
}).toList();
seriesService.updateAccessControl(seriesID, new AccessControlList(accessControlEntries));
return ApiResponses.Json.ok(VERSION_1_0_0, aclJson);
}
@SuppressWarnings("unchecked")
@PUT
@Produces({ "application/json", "application/v1.0.0+json" })
@Path("{seriesId}/properties")
@RestQuery(name = "updateseriesproperties", description = "Updates a series' properties", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, restParameters = {
@RestParameter(name = "properties", isRequired = true, description = "Series properties", type = STRING) }, reponses = {
@RestResponse(description = "Successfully updated the series' properties.", responseCode = HttpServletResponse.SC_OK),
@RestResponse(description = "The specified series does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
public Response updateSeriesProperties(@HeaderParam("Accept") String acceptHeader,
@PathParam("seriesId") String seriesID, @FormParam("properties") String propertiesJson)
throws NotFoundException, SeriesException, UnauthorizedException {
if (StringUtils.isBlank(propertiesJson))
return R.badRequest("Missing form parameter 'acl'");
JSONObject props;
try {
props = (JSONObject) parser.parse(propertiesJson);
} catch (ParseException e) {
logger.debug("Could not parse properties ({}): {}", propertiesJson, getStackTrace(e));
return R.badRequest("Could not parse series properties");
}
for (Object prop : props.entrySet()) {
Entry<String, Object> field = (Entry<String, Object>) prop;
seriesService.updateSeriesProperty(seriesID, field.getKey(), field.getValue().toString());
}
return ApiResponses.Json.ok(VERSION_1_0_0, propertiesJson);
}
/**
* Parse two strings in UTC format into Date objects to represent a range of dates.
*
* @param createdFrom
* The string that represents the start date of the range.
* @param createdTo
* The string that represents the end date of the range.
* @return A Tuple with the two Dates
* @throws IllegalArgumentException
* Thrown if the input strings are not valid UTC strings
*/
private Tuple<Date, Date> getFromAndToCreationRange(String createdFrom, String createdTo) {
Date createdFromDate = null;
Date createdToDate = null;
if ((StringUtils.isNotBlank(createdFrom) && StringUtils.isBlank(createdTo))
|| (StringUtils.isBlank(createdFrom) && StringUtils.isNotBlank(createdTo))) {
logger.error("Both createdTo '{}' and createdFrom '{}' have to be specified or neither of them", createdTo,
createdFrom);
throw new IllegalArgumentException("Both createdTo '" + createdTo + "' and createdFrom '" + createdFrom
+ "' have to be specified or neither of them");
} else {
if (StringUtils.isNotBlank(createdFrom)) {
try {
createdFromDate = new Date(DateTimeSupport.fromUTC(createdFrom));
} catch (IllegalStateException e) {
logger.error("Unable to parse createdFrom parameter '{}':{}", createdFrom, ExceptionUtils.getStackTrace(e));
throw new IllegalArgumentException("Unable to parse createdFrom parameter.");
} catch (java.text.ParseException e) {
logger.error("Unable to parse createdFrom parameter '{}':{}", createdFrom, ExceptionUtils.getStackTrace(e));
throw new IllegalArgumentException("Unable to parse createdFrom parameter.");
}
}
if (StringUtils.isNotBlank(createdTo)) {
try {
createdToDate = new Date(DateTimeSupport.fromUTC(createdTo));
} catch (IllegalStateException e) {
logger.error("Unable to parse createdTo parameter '{}':{}", createdTo, ExceptionUtils.getStackTrace(e));
throw new IllegalArgumentException("Unable to parse createdTo parameter.");
} catch (java.text.ParseException e) {
logger.error("Unable to parse createdTo parameter '{}':{}", createdTo, ExceptionUtils.getStackTrace(e));
throw new IllegalArgumentException("Unable to parse createdTo parameter.");
}
}
}
return new Tuple<>(createdFromDate, createdToDate);
}
private String getSeriesUrl(String seriesId) {
return UrlSupport.concat(serverUrl, serviceUrl, seriesId);
}
}