org.opencastproject.external.endpoint.SeriesEndpoint Maven / Gradle / Ivy
The newest version!
/*
* 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 javax.servlet.http.HttpServletResponse.SC_OK;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.trimToNull;
import static org.apache.http.HttpStatus.SC_UNAUTHORIZED;
import static org.opencastproject.external.common.ApiVersion.VERSION_1_1_0;
import static org.opencastproject.external.common.ApiVersion.VERSION_1_2_0;
import static org.opencastproject.external.common.ApiVersion.VERSION_1_5_0;
import static org.opencastproject.util.DateTimeSupport.toUTC;
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.STRING;
import org.opencastproject.elasticsearch.api.SearchIndexException;
import org.opencastproject.elasticsearch.api.SearchResult;
import org.opencastproject.elasticsearch.api.SearchResultItem;
import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
import org.opencastproject.elasticsearch.index.QueryPreprocessor;
import org.opencastproject.elasticsearch.index.objects.event.EventIndexSchema;
import org.opencastproject.elasticsearch.index.objects.series.Series;
import org.opencastproject.elasticsearch.index.objects.series.SeriesIndexSchema;
import org.opencastproject.elasticsearch.index.objects.series.SeriesSearchQuery;
import org.opencastproject.external.common.ApiMediaType;
import org.opencastproject.external.common.ApiResponses;
import org.opencastproject.external.common.ApiVersion;
import org.opencastproject.external.util.AclUtils;
import org.opencastproject.external.util.ExternalMetadataUtils;
import org.opencastproject.index.service.api.IndexService;
import org.opencastproject.index.service.exception.IndexServiceException;
import org.opencastproject.index.service.util.RequestUtils;
import org.opencastproject.index.service.util.RestUtils;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.metadata.dublincore.DublinCore;
import org.opencastproject.metadata.dublincore.DublinCoreMetadataCollection;
import org.opencastproject.metadata.dublincore.MetadataField;
import org.opencastproject.metadata.dublincore.MetadataJson;
import org.opencastproject.metadata.dublincore.MetadataList;
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.AccessControlParser;
import org.opencastproject.security.api.Permissions;
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.OpencastConstants;
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 org.opencastproject.util.requests.SortCriterion;
import com.entwinemedia.fn.Fn;
import com.entwinemedia.fn.data.Opt;
import com.entwinemedia.fn.data.json.Field;
import com.entwinemedia.fn.data.json.JObject;
import com.entwinemedia.fn.data.json.JValue;
import com.entwinemedia.fn.data.json.Jsons.Functions;
import org.apache.commons.lang3.StringUtils;
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.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
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.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
@Path("/")
@Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_0_0, 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 = "externalapiseries", title = "External API Series Service", notes = {},
abstractText = "Provides resources and operations related to the series")
@Component(
immediate = true,
service = SeriesEndpoint.class,
property = {
"service.description=External API - Series Endpoint",
"opencast.service.type=org.opencastproject.external",
"opencast.service.path=/api/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);
/** Base URL of this endpoint */
protected String endpointBaseUrl;
/* OSGi service references */
private ElasticsearchIndex elasticsearchIndex;
private IndexService indexService;
private SecurityService securityService;
private SeriesService seriesService;
/** OSGi DI */
@Reference
void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
this.elasticsearchIndex = elasticsearchIndex;
}
/** OSGi DI */
@Reference
void setIndexService(IndexService indexService) {
this.indexService = indexService;
}
/** OSGi DI */
@Reference
void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
/** OSGi DI */
@Reference
void setSeriesService(SeriesService seriesService) {
this.seriesService = seriesService;
}
/** OSGi activation method */
@Activate
void activate(ComponentContext cc) {
logger.info("Activating External API - Series Endpoint");
final Tuple 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);
}
@GET
@Path("")
@RestQuery(name = "getseries", description = "Returns a list of series.", returnDescription = "", restParameters = {
@RestParameter(name = "onlyWithWriteAccess", isRequired = false, description = "Whether only to get the series to which we have write access.", type = RestParameter.Type.BOOLEAN),
@RestParameter(name = "filter", isRequired = false, description = "Usage :. Filters can combine using a comma \",\". Available Filters: managedAcl, contributors, CreationDate, Creator, textFilter, language, license, organizers, subject, title. If API ver > 1.1.0 also: identifier, description, creator, publishers, rightsholder.", 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: :ASC or :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 = "The index of the first result to return.", isRequired = false, type = RestParameter.Type.INTEGER),
@RestParameter(name = "withacl", isRequired = false, description = "Whether the acl should be included in the response.", type = RestParameter.Type.BOOLEAN)
}, responses = {
@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, @QueryParam("onlyWithWriteAccess") Boolean onlyWithWriteAccess,
@QueryParam("withacl") Boolean withAcl) throws UnauthorizedException {
final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
if (requestedVersion.isSmallerThan(VERSION_1_5_0)) {
// withAcl was added for version 1.5.0 and should be ignored for smaller versions.
withAcl = false;
}
try {
SeriesSearchQuery query = new SeriesSearchQuery(securityService.getOrganization().getId(),
securityService.getUser());
Option 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(":",2);
if (filterTuple.length < 2) {
logger.debug("Filter {} not valid: {}", filterTuple[0], filter);
continue;
}
String name = filterTuple[0];
String value;
if (!requestedVersion.isSmallerThan(VERSION_1_1_0)) {
// MH-13038 - 1.1.0 and higher support semi-colons in values
value = f.substring(name.length() + 1);
} else {
value = filterTuple[1];
}
if ("managedAcl".equals(name)) {
query.withManagedAcl(value);
} else if ("contributors".equals(name)) {
query.withContributor(value);
} else if ("CreationDate".equals(name)) {
try {
Tuple fromAndToCreationRange = getFromAndToCreationRange(value.split("/")[0],
value.split("/")[1]);
query.withCreatedFrom(fromAndToCreationRange.getA());
query.withCreatedTo(fromAndToCreationRange.getB());
} catch (IllegalArgumentException e) {
return RestUtil.R.badRequest(e.getMessage());
} catch (ArrayIndexOutOfBoundsException e) {
String dateErrorMsg = String.format("Filter Series API error: Malformed date period. "
+ "Correct UTC time period format: yyyy-MM-ddTHH:mm:ssZ/yyyy-MM-ddTHH:mm:ssZ, "
+ "stated date period string: \"%s\"", value);
logger.warn(dateErrorMsg);
return RestUtil.R.badRequest(dateErrorMsg);
}
} else if ("Creator".equals(name)) {
query.withCreator(value);
} else if ("textFilter".equals(name)) {
query.withText(QueryPreprocessor.sanitize(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);
} else if (!requestedVersion.isSmallerThan(VERSION_1_1_0)) {
// additional filters only available with Version 1.1.0 or higher
if ("identifier".equals(name)) {
query.withIdentifier(value);
} else if ("description".equals(name)) {
query.withDescription(value);
} else if ("creator".equals(name)) {
query.withCreator(value);
} else if ("publishers".equals(name)) {
query.withPublisher(value);
} else if ("rightsholder".equals(name)) {
query.withRightsHolder(value);
} else {
logger.warn("Unknown filter criteria {}", name);
return Response.status(SC_BAD_REQUEST).build();
}
}
}
}
if (optSort.isSome()) {
Set 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 sort criteria {}", criterion.getFieldName());
return Response.status(SC_BAD_REQUEST).build();
}
}
}
if (onlyWithWriteAccess != null && onlyWithWriteAccess) {
query.withoutActions();
query.withAction(Permissions.Action.WRITE);
}
logger.trace("Using Query: " + query.toString());
SearchResult result = elasticsearchIndex.getByQuery(query);
final boolean includeAcl = (withAcl != null && withAcl);
return queryResultToJson(result, includeAcl, requestedVersion);
} catch (Exception e) {
logger.warn("Could not perform search query", e);
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
}
private Response queryResultToJson(SearchResult result, boolean includeAcl, ApiVersion requestedVersion) {
return ApiResponses.Json.ok(requestedVersion, arr($(result.getItems()).map(new Fn, JValue>() {
@Override
public JValue apply(SearchResultItem a) {
final Series s = a.getSource();
JValue subjects;
if (s.getSubject() == null) {
subjects = arr();
} else {
subjects = arr(splitSubjectIntoArray(s.getSubject()));
}
Date createdDate = s.getCreatedDateTime();
JObject result;
if (requestedVersion.isSmallerThan(VERSION_1_1_0)) {
result = obj(
f("identifier", v(s.getIdentifier())),
f("title", v(s.getTitle())),
f("creator", v(s.getCreator(), BLANK)),
f("created", v(createdDate != null ? toUTC(createdDate.getTime()) : null, BLANK)),
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))));
}
else {
result = obj(
f("identifier", v(s.getIdentifier())),
f("title", v(s.getTitle())),
f("description", v(s.getDescription(), BLANK)),
f("creator", v(s.getCreator(), BLANK)),
f("created", v(createdDate != null ? toUTC(createdDate.getTime()) : null, BLANK)),
f("subjects", subjects),
f("contributors", arr($(s.getContributors()).map(Functions.stringToJValue))),
f("organizers", arr($(s.getOrganizers()).map(Functions.stringToJValue))),
f("language", v(s.getLanguage(), BLANK)),
f("license", v(s.getLicense(), BLANK)),
f("rightsholder", v(s.getRightsHolder(), BLANK)),
f("publishers", arr($(s.getPublishers()).map(Functions.stringToJValue))));
if (includeAcl) {
AccessControlList acl = getAclFromSeries(s);
result = result.merge(f("acl", arr(AclUtils.serializeAclToJson(acl))));
}
}
return result;
}
}).toList()));
}
/**
* Get an {@link AccessControlList} from a {@link Series}.
*
* @param series
* The {@link Series} to get the ACL from.
* @return The {@link AccessControlList} stored in the {@link Series}
*/
private static AccessControlList getAclFromSeries(Series series) {
AccessControlList activeAcl = new AccessControlList();
try {
if (series.getAccessPolicy() != null) {
activeAcl = AccessControlParser.parseAcl(series.getAccessPolicy());
}
} catch (Exception e) {
logger.error("Unable to parse access policy", e);
}
return activeAcl;
}
@GET
@Path("{seriesId}")
@RestQuery(name = "getseries", description = "Returns a single series.", returnDescription = "",
pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
}, restParameters = {
@RestParameter(name = "withacl", isRequired = false, type = RestParameter.Type.BOOLEAN,
description = "Whether the acl should be included in the response.")
}, responses = {
@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,
@QueryParam("withacl") Boolean withAcl)
throws Exception {
final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
if (requestedVersion.isSmallerThan(VERSION_1_5_0)) {
// withAcl was added for version 1.5.0 and should be ignored for smaller versions.
withAcl = false;
}
Optional optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(), securityService.getUser());
if (optSeries.isPresent()) {
final Series s = optSeries.get();
JValue subjects;
if (s.getSubject() == null) {
subjects = arr();
} else {
subjects = arr(splitSubjectIntoArray(s.getSubject()));
}
Date createdDate = s.getCreatedDateTime();
JObject responseContent;
if (requestedVersion.isSmallerThan(VERSION_1_1_0)) {
responseContent = obj(
f("identifier", v(s.getIdentifier())),
f("title", v(s.getTitle())),
f("description", v(s.getDescription(), BLANK)),
f("creator", v(s.getCreator(), BLANK)),
f("subjects", subjects),
f("organization", v(s.getOrganization())),
f("created", v(createdDate != null ? toUTC(createdDate.getTime()) : null, BLANK)),
f("contributors", arr($(s.getContributors()).map(Functions.stringToJValue))),
f("organizers", arr($(s.getOrganizers()).map(Functions.stringToJValue))),
// For compatibility (MH-13405)
f("opt_out", false),
f("publishers", arr($(s.getPublishers()).map(Functions.stringToJValue))));
}
else {
responseContent = obj(
f("identifier", v(s.getIdentifier())),
f("title", v(s.getTitle())),
f("description", v(s.getDescription(), BLANK)),
f("creator", v(s.getCreator(), BLANK)),
f("subjects", subjects),
f("organization", v(s.getOrganization())),
f("created", v(createdDate != null ? toUTC(createdDate.getTime()) : null, BLANK)),
f("contributors", arr($(s.getContributors()).map(Functions.stringToJValue))),
f("organizers", arr($(s.getOrganizers()).map(Functions.stringToJValue))),
// For compatibility (MH-13405)
f("opt_out", false),
f("publishers", arr($(s.getPublishers()).map(Functions.stringToJValue))),
f("language", v(s.getLanguage(), BLANK)),
f("license", v(s.getLicense(), BLANK)),
f("rightsholder", v(s.getRightsHolder(), BLANK)));
if (withAcl != null && withAcl) {
AccessControlList acl = getAclFromSeries(s);
responseContent = responseContent.merge(f("acl", arr(AclUtils.serializeAclToJson(acl))));
}
}
return ApiResponses.Json.ok(requestedVersion, responseContent);
}
return ApiResponses.notFound("Cannot find an series with id '%s'.", id);
}
private List splitSubjectIntoArray(final String subject) {
return com.entwinemedia.fn.Stream.$(subject.split(",")).map(new Fn() {
@Override
public JValue apply(String a) {
return v(a.trim());
}
}).toList();
}
@GET
@Path("{seriesId}/metadata")
@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) }, responses = {
@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 {
final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
if (StringUtils.trimToNull(type) == null) {
return getAllMetadata(id, requestedVersion);
} else {
return getMetadataByType(id, type, requestedVersion);
}
}
private Response getAllMetadata(String id, ApiVersion requestedVersion) throws SearchIndexException {
Optional optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(), securityService.getUser());
if (optSeries.isEmpty())
return ApiResponses.notFound("Cannot find a series with id '%s'.", id);
MetadataList metadataList = new MetadataList();
List catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
for (SeriesCatalogUIAdapter adapter : catalogUIAdapters) {
final Opt optSeriesMetadata = adapter.getFields(id);
if (optSeriesMetadata.isSome()) {
metadataList.add(adapter.getFlavor().toString(), adapter.getUITitle(), optSeriesMetadata.get());
}
}
DublinCoreMetadataCollection collection = getSeriesMetadata(optSeries.get());
ExternalMetadataUtils.changeSubjectToSubjects(collection);
metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), collection);
return ApiResponses.Json.ok(requestedVersion, MetadataJson.listToJson(metadataList, false));
}
private Response getMetadataByType(String id, String type, ApiVersion requestedVersion) throws SearchIndexException {
Optional optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(), securityService.getUser());
if (optSeries.isEmpty())
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())) {
DublinCoreMetadataCollection collection = getSeriesMetadata(optSeries.get());
ExternalMetadataUtils.changeSubjectToSubjects(collection);
return ApiResponses.Json.ok(requestedVersion, MetadataJson.collectionToJson(collection, false));
}
// Try the other catalogs
List catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
for (SeriesCatalogUIAdapter adapter : catalogUIAdapters) {
if (typeMatchesSeriesCatalogUIAdapter(type, adapter)) {
final Opt optSeriesMetadata = adapter.getFields(id);
if (optSeriesMetadata.isSome()) {
return ApiResponses.Json.ok(requestedVersion, MetadataJson.collectionToJson(optSeriesMetadata.get(), true));
}
}
}
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 DublinCoreMetadataCollection} instance with all the series metadata
*/
private DublinCoreMetadataCollection getSeriesMetadata(Series series) {
DublinCoreMetadataCollection metadata = indexService.getCommonSeriesCatalogUIAdapter().getRawFields();
MetadataField title = metadata.getOutputFields().get(DublinCore.PROPERTY_TITLE.getLocalName());
metadata.removeField(title);
MetadataField newTitle = new MetadataField(title);
newTitle.setValue(series.getTitle());
metadata.addField(newTitle);
MetadataField subject = metadata.getOutputFields().get(DublinCore.PROPERTY_SUBJECT.getLocalName());
metadata.removeField(subject);
MetadataField newSubject = new MetadataField(subject);
newSubject.setValue(series.getSubject());
metadata.addField(newSubject);
MetadataField description = metadata.getOutputFields().get(DublinCore.PROPERTY_DESCRIPTION.getLocalName());
metadata.removeField(description);
MetadataField newDescription = new MetadataField(description);
newDescription.setValue(series.getDescription());
metadata.addField(newDescription);
MetadataField language = metadata.getOutputFields().get(DublinCore.PROPERTY_LANGUAGE.getLocalName());
metadata.removeField(language);
MetadataField newLanguage = new MetadataField(language);
newLanguage.setValue(series.getLanguage());
metadata.addField(newLanguage);
MetadataField rightsHolder = metadata.getOutputFields().get(DublinCore.PROPERTY_RIGHTS_HOLDER.getLocalName());
metadata.removeField(rightsHolder);
MetadataField newRightsHolder = new MetadataField(rightsHolder);
newRightsHolder.setValue(series.getRightsHolder());
metadata.addField(newRightsHolder);
MetadataField license = metadata.getOutputFields().get(DublinCore.PROPERTY_LICENSE.getLocalName());
metadata.removeField(license);
MetadataField newLicense = new MetadataField(license);
newLicense.setValue(series.getLicense());
metadata.addField(newLicense);
MetadataField organizers = metadata.getOutputFields().get(DublinCore.PROPERTY_CREATOR.getLocalName());
metadata.removeField(organizers);
MetadataField newOrganizers = new MetadataField(organizers);
newOrganizers.setValue(StringUtils.join(series.getOrganizers(), ", "));
metadata.addField(newOrganizers);
MetadataField contributors = metadata.getOutputFields().get(DublinCore.PROPERTY_CONTRIBUTOR.getLocalName());
metadata.removeField(contributors);
MetadataField newContributors = new MetadataField(contributors);
newContributors.setValue(StringUtils.join(series.getContributors(), ", "));
metadata.addField(newContributors);
MetadataField publishers = metadata.getOutputFields().get(DublinCore.PROPERTY_PUBLISHER.getLocalName());
metadata.removeField(publishers);
MetadataField newPublishers = new MetadataField(publishers);
newPublishers.setValue(StringUtils.join(series.getPublishers(), ", "));
metadata.addField(newPublishers);
// Admin UI only field
MetadataField createdBy = new MetadataField(
"createdBy",
null,
"EVENTS.SERIES.DETAILS.METADATA.CREATED_BY",
true,
false,
null,
null,
MetadataField.Type.TEXT,
null,
null,
CREATED_BY_UI_ORDER,
null,
null,
null,
null);
createdBy.setValue(series.getCreator());
metadata.addField(createdBy);
MetadataField uid = metadata.getOutputFields().get(DublinCore.PROPERTY_IDENTIFIER.getLocalName());
metadata.removeField(uid);
MetadataField newUID = new MetadataField(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().toString());
try {
MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(type);
return flavor.equals(catalogFlavor);
} catch (IllegalArgumentException e) {
return false;
}
}
private Opt getFlavor(String flavorString) {
try {
MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(flavorString);
return Opt.some(flavor);
} catch (IllegalArgumentException e) {
return Opt.none();
}
}
@PUT
@Path("{seriesId}/metadata")
@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) }, responses = {
@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 updatedFields;
try {
updatedFields = RequestUtils.getKeyValueMap(metadataJSON);
} catch (ParseException e) {
logger.debug("Unable to update series '{}' with metadata type '{}' and content '{}'", id, type, metadataJSON, e);
return RestUtil.R.badRequest(String.format("Unable to parse metadata fields as json from '%s' because '%s'",
metadataJSON, e.getMessage()));
} 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 optCollection = Opt.none();
SeriesCatalogUIAdapter adapter = null;
Optional optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(), securityService.getUser());
if (optSeries.isEmpty())
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 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 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);
}
DublinCoreMetadataCollection 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(MetadataJson.copyWithDifferentJsonValue(field, updatedFields.get(key)));
}
metadataList.add(adapter, collection);
indexService.updateAllSeriesMetadata(id, metadataList, elasticsearchIndex);
return ApiResponses.Json.ok(acceptHeader, "");
}
@DELETE
@Path("{seriesId}/metadata")
@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) }, responses = {
@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 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();
}
Optional optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(), securityService.getUser());
if (optSeries.isEmpty())
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 Response.noContent().build();
}
@GET
@Path("{seriesId}/acl")
@RestQuery(name = "getseriesacl", description = "Returns a series' access policy.", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, responses = {
@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(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id) throws Exception {
final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
JSONParser parser = new JSONParser();
Optional optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(), securityService.getUser());
if (optSeries.isPresent()) {
Series series = optSeries.get();
// The ACL is stored as JSON string in the index. Parse it and extract the part we want to have in the API.
if (series.getAccessPolicy() == null) {
return ApiResponses.notFound("Acl for series with id '%s' is not defined.", id);
}
JSONObject acl = (JSONObject) parser.parse(series.getAccessPolicy());
if (!((JSONObject) acl.get("acl")).containsKey("ace")) {
return ApiResponses.notFound("Cannot find acl for series with id '%s'.", id);
} else {
return ApiResponses.Json.ok(requestedVersion, ((JSONArray) ((JSONObject) acl.get("acl")).get("ace")).toJSONString());
}
}
return ApiResponses.notFound("Cannot find an series with id '%s'.", id);
}
@GET
@Path("{seriesId}/properties")
@RestQuery(name = "getseriesproperties", description = "Returns a series' properties", returnDescription = "", pathParameters = {
@RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING) }, responses = {
@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(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id) throws Exception {
if (elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(), securityService.getUser()).isPresent()) {
final Map properties = seriesService.getSeriesProperties(id);
return ApiResponses.Json.ok(acceptHeader, obj($(properties.entrySet()).map(new Fn, Field>() {
@Override
public Field apply(Entry a) {
return f(a.getKey(), v(a.getValue(), 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) }, responses = {
@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, 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) }, responses = {
@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 seriesID,
@FormParam("metadata") String metadataJSON)
throws UnauthorizedException, NotFoundException, SearchIndexException {
try {
MetadataList metadataList = indexService.updateAllSeriesMetadata(seriesID, metadataJSON, elasticsearchIndex);
return ApiResponses.Json.ok(acceptHeader, MetadataJson.listToJson(metadataList, true));
} catch (IllegalArgumentException e) {
logger.debug("Unable to update series '{}' with metadata '{}'", seriesID, metadataJSON, e);
return RestUtil.R.badRequest(e.getMessage());
} catch (IndexServiceException e) {
logger.error("Unable to update series '{}' with metadata '{}'", seriesID, metadataJSON, e);
return RestUtil.R.serverError();
}
}
@POST
@Path("")
@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 = true, type = STRING),
@RestParameter(name = "theme", description = "The theme ID to be applied to the series", isRequired = false, type = STRING) }, responses = {
@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");
if (isBlank(aclParam))
return R.badRequest("Required parameter 'acl' is missing or invalid");
MetadataList metadataList;
try {
metadataList = deserializeMetadataList(metadataParam);
} catch (ParseException e) {
logger.debug("Unable to parse series metadata '{}'", metadataParam, e);
return R.badRequest(String.format("Unable to parse metadata because '%s'", e.getMessage()));
} 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 '{}'", metadataParam, e);
return R.badRequest(e.getMessage());
}
Map options = new TreeMap<>();
Opt 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 '{}'", aclParam, 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 '{}'", aclParam, e);
return R.badRequest(e.getMessage());
}
try {
String seriesId = indexService.createSeries(metadataList, options, Opt.some(acl), optThemeId);
return ApiResponses.Json.created(acceptHeader, URI.create(getSeriesUrl(seriesId)),
obj(f("identifier", v(seriesId, BLANK))));
} catch (IndexServiceException e) {
logger.error("Unable to create series with metadata '{}', acl '{}', theme '{}'",
metadataParam, aclParam, themeIdParam, 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();
JSONParser parser = new JSONParser();
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);
DublinCoreMetadataCollection collection = null;
SeriesCatalogUIAdapter adapter = null;
for (SeriesCatalogUIAdapter seriesCatalogUIAdapter : indexService.getSeriesCatalogUIAdapters()) {
MediaPackageElementFlavor catalogFlavor = MediaPackageElementFlavor
.parseFlavor(seriesCatalogUIAdapter.getFlavor().toString());
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 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(
MetadataJson.copyWithDifferentJsonValue(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(MetadataJson.copyWithDifferentJsonValue(field, fields.get(key)));
}
}
}
metadataList.add(adapter, collection);
}
return metadataList;
}
@PUT
@Path("{seriesId}/acl")
@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),
@RestParameter(name = "override", isRequired = false, description = "If true the series ACL will take precedence over any existing episode ACL", type = STRING)}, responses = {
@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, @DefaultValue("false") @FormParam("override") boolean override)
throws NotFoundException, SeriesException, UnauthorizedException {
if (isBlank(aclJson))
return R.badRequest("Missing form parameter 'acl'");
final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
if (requestedVersion.isSmallerThan(VERSION_1_2_0)) {
// override was added in version 1.2.0 and should be ignored for smaller versions
override = false;
}
JSONParser parser = new JSONParser();
JSONArray acl;
try {
acl = (JSONArray) parser.parse(aclJson);
} catch (ParseException e) {
logger.debug("Could not parse ACL ({})", aclJson, e);
return R.badRequest("Could not parse ACL");
}
List accessControlEntries = $(acl.toArray()).map(new Fn
© 2015 - 2025 Weber Informatics LLC | Privacy Policy