View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  package org.opencastproject.external.endpoint;
22  
23  import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
24  import static javax.servlet.http.HttpServletResponse.SC_OK;
25  import static org.apache.commons.lang3.StringUtils.isBlank;
26  import static org.apache.commons.lang3.StringUtils.trimToNull;
27  import static org.apache.http.HttpStatus.SC_UNAUTHORIZED;
28  import static org.opencastproject.external.common.ApiVersion.VERSION_1_1_0;
29  import static org.opencastproject.external.common.ApiVersion.VERSION_1_2_0;
30  import static org.opencastproject.external.common.ApiVersion.VERSION_1_5_0;
31  import static org.opencastproject.index.service.util.JSONUtils.collectionToJsonArray;
32  import static org.opencastproject.index.service.util.JSONUtils.safeString;
33  import static org.opencastproject.util.DateTimeSupport.toUTC;
34  import static org.opencastproject.util.RestUtil.getEndpointUrl;
35  import static org.opencastproject.util.doc.rest.RestParameter.Type.BOOLEAN;
36  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
37  
38  import org.opencastproject.elasticsearch.api.SearchIndexException;
39  import org.opencastproject.elasticsearch.api.SearchResult;
40  import org.opencastproject.elasticsearch.api.SearchResultItem;
41  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
42  import org.opencastproject.elasticsearch.index.objects.event.EventIndexSchema;
43  import org.opencastproject.elasticsearch.index.objects.series.Series;
44  import org.opencastproject.elasticsearch.index.objects.series.SeriesIndexSchema;
45  import org.opencastproject.elasticsearch.index.objects.series.SeriesSearchQuery;
46  import org.opencastproject.external.common.ApiMediaType;
47  import org.opencastproject.external.common.ApiResponseBuilder;
48  import org.opencastproject.external.common.ApiVersion;
49  import org.opencastproject.external.util.AclUtils;
50  import org.opencastproject.external.util.ExternalMetadataUtils;
51  import org.opencastproject.index.service.api.IndexService;
52  import org.opencastproject.index.service.exception.IndexServiceException;
53  import org.opencastproject.index.service.util.RequestUtils;
54  import org.opencastproject.index.service.util.RestUtils;
55  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
56  import org.opencastproject.metadata.dublincore.DublinCore;
57  import org.opencastproject.metadata.dublincore.DublinCoreMetadataCollection;
58  import org.opencastproject.metadata.dublincore.MetadataField;
59  import org.opencastproject.metadata.dublincore.MetadataJson;
60  import org.opencastproject.metadata.dublincore.MetadataList;
61  import org.opencastproject.metadata.dublincore.SeriesCatalogUIAdapter;
62  import org.opencastproject.rest.RestConstants;
63  import org.opencastproject.security.api.AccessControlEntry;
64  import org.opencastproject.security.api.AccessControlList;
65  import org.opencastproject.security.api.AccessControlParser;
66  import org.opencastproject.security.api.Permissions;
67  import org.opencastproject.security.api.SecurityService;
68  import org.opencastproject.security.api.UnauthorizedException;
69  import org.opencastproject.series.api.SeriesException;
70  import org.opencastproject.series.api.SeriesService;
71  import org.opencastproject.systems.OpencastConstants;
72  import org.opencastproject.util.DateTimeSupport;
73  import org.opencastproject.util.NotFoundException;
74  import org.opencastproject.util.RestUtil;
75  import org.opencastproject.util.RestUtil.R;
76  import org.opencastproject.util.UrlSupport;
77  import org.opencastproject.util.data.Tuple;
78  import org.opencastproject.util.doc.rest.RestParameter;
79  import org.opencastproject.util.doc.rest.RestQuery;
80  import org.opencastproject.util.doc.rest.RestResponse;
81  import org.opencastproject.util.doc.rest.RestService;
82  import org.opencastproject.util.requests.SortCriterion;
83  
84  import com.google.gson.JsonArray;
85  import com.google.gson.JsonObject;
86  import com.google.gson.JsonPrimitive;
87  
88  import org.apache.commons.lang3.StringUtils;
89  import org.json.simple.JSONArray;
90  import org.json.simple.JSONObject;
91  import org.json.simple.parser.JSONParser;
92  import org.json.simple.parser.ParseException;
93  import org.osgi.service.component.ComponentContext;
94  import org.osgi.service.component.annotations.Activate;
95  import org.osgi.service.component.annotations.Component;
96  import org.osgi.service.component.annotations.Reference;
97  import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
98  import org.slf4j.Logger;
99  import org.slf4j.LoggerFactory;
100 
101 import java.net.URI;
102 import java.text.SimpleDateFormat;
103 import java.util.ArrayList;
104 import java.util.Date;
105 import java.util.List;
106 import java.util.Locale;
107 import java.util.Map;
108 import java.util.Map.Entry;
109 import java.util.Optional;
110 import java.util.TreeMap;
111 import java.util.stream.Collectors;
112 
113 import javax.servlet.http.HttpServletResponse;
114 import javax.ws.rs.DELETE;
115 import javax.ws.rs.DefaultValue;
116 import javax.ws.rs.FormParam;
117 import javax.ws.rs.GET;
118 import javax.ws.rs.HeaderParam;
119 import javax.ws.rs.POST;
120 import javax.ws.rs.PUT;
121 import javax.ws.rs.Path;
122 import javax.ws.rs.PathParam;
123 import javax.ws.rs.Produces;
124 import javax.ws.rs.QueryParam;
125 import javax.ws.rs.WebApplicationException;
126 import javax.ws.rs.core.MediaType;
127 import javax.ws.rs.core.Response;
128 import javax.ws.rs.core.Response.Status;
129 
130 @Path("/api/series")
131 @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_0_0, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0,
132             ApiMediaType.VERSION_1_3_0, ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0,
133             ApiMediaType.VERSION_1_6_0, ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0,
134             ApiMediaType.VERSION_1_9_0, ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
135 @RestService(
136     name = "externalapiseries",
137     title = "External API Series Service",
138     notes = {},
139     abstractText = "Provides resources and operations related to the series"
140 )
141 @Component(
142     immediate = true,
143     service = SeriesEndpoint.class,
144     property = {
145         "service.description=External API - Series Endpoint",
146         "opencast.service.type=org.opencastproject.external",
147         "opencast.service.path=/api/series"
148     }
149 )
150 @JaxrsResource
151 public class SeriesEndpoint {
152 
153   private static final int CREATED_BY_UI_ORDER = 9;
154   private static final int DEFAULT_LIMIT = 100;
155 
156   private static final Logger logger = LoggerFactory.getLogger(SeriesEndpoint.class);
157 
158   /** Base URL of this endpoint */
159   protected String endpointBaseUrl;
160 
161   /* OSGi service references */
162   private ElasticsearchIndex elasticsearchIndex;
163   private IndexService indexService;
164   private SecurityService securityService;
165   private SeriesService seriesService;
166 
167   /** OSGi DI */
168   @Reference
169   void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
170     this.elasticsearchIndex = elasticsearchIndex;
171   }
172 
173   /** OSGi DI */
174   @Reference
175   void setIndexService(IndexService indexService) {
176     this.indexService = indexService;
177   }
178 
179   /** OSGi DI */
180   @Reference
181   void setSecurityService(SecurityService securityService) {
182     this.securityService = securityService;
183   }
184 
185   /** OSGi DI */
186   @Reference
187   void setSeriesService(SeriesService seriesService) {
188     this.seriesService = seriesService;
189   }
190 
191   /** OSGi activation method */
192   @Activate
193   void activate(ComponentContext cc) {
194     logger.info("Activating External API - Series Endpoint");
195 
196     final Tuple<String, String> endpointUrl = getEndpointUrl(cc, OpencastConstants.EXTERNAL_API_URL_ORG_PROPERTY,
197             RestConstants.SERVICE_PATH_PROPERTY);
198     endpointBaseUrl = UrlSupport.concat(endpointUrl.getA(), endpointUrl.getB());
199     logger.debug("Configured service endpoint is {}", endpointBaseUrl);
200   }
201 
202   @GET
203   @Path("")
204   @RestQuery(
205       name = "getseries",
206       description = "Returns a list of series.",
207       returnDescription = "",
208       restParameters = {
209           @RestParameter(name = "onlyWithWriteAccess", isRequired = false, description = "Whether only to get the "
210               + "series to which we have write access.", type = RestParameter.Type.BOOLEAN),
211           @RestParameter(name = "filter", isRequired = false, description = "Usage <Filter Name>:<Value to Filter "
212               + "With>. Filters can combine using a comma \",\". Available Filters: managedAcl, contributors, "
213               + "CreationDate, Creator, textFilter, language, license, organizers, subject, title. If API ver > 1.1.0 "
214               + "also: identifier, description, creator, publishers, rightsholder.", type = STRING),
215           @RestParameter(name = "sort", description = "Sort the results based upon a list of comma seperated sorting "
216               + "criteria. In the comma seperated list each type of sorting is specified as a pair such as: <Sort "
217               + "Name>:ASC or <Sort Name>:DESC. Adding the suffix ASC or DESC sets the order as ascending or "
218               + "descending order and is mandatory.", isRequired = false, type = STRING),
219           @RestParameter(name = "limit", description = "The maximum number of results to return for a single request.",
220               isRequired = false, type = RestParameter.Type.INTEGER),
221           @RestParameter(name = "offset", description = "The index of the first result to return.", isRequired = false,
222               type = RestParameter.Type.INTEGER),
223           @RestParameter(name = "withacl", isRequired = false, description = "Whether the acl should be included in "
224               + "the response.", type = RestParameter.Type.BOOLEAN)
225       },
226       responses = {
227           @RestResponse(description = "A (potentially empty) list of series is returned.",
228               responseCode = HttpServletResponse.SC_OK)
229       })
230   public Response getSeriesList(@HeaderParam("Accept") String acceptHeader, @QueryParam("filter") String filter,
231           @QueryParam("sort") String sort, @QueryParam("order") String order, @QueryParam("offset") int offset,
232           @QueryParam("limit") int limit, @QueryParam("onlyWithWriteAccess") Boolean onlyWithWriteAccess,
233           @QueryParam("withacl") Boolean withAcl) throws UnauthorizedException {
234     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
235     if (requestedVersion.isSmallerThan(VERSION_1_5_0)) {
236       // withAcl was added for version 1.5.0 and should be ignored for smaller versions.
237       withAcl = false;
238     }
239     try {
240       SeriesSearchQuery query = new SeriesSearchQuery(securityService.getOrganization().getId(),
241               securityService.getUser());
242       Optional<String> optSort = Optional.ofNullable(trimToNull(sort));
243 
244       if (offset > 0) {
245         query.withOffset(offset);
246       }
247 
248       // If limit is 0, we set the default limit
249       query.withLimit(limit < 1 ? DEFAULT_LIMIT : limit);
250 
251       // Parse the filters
252       if (StringUtils.isNotBlank(filter)) {
253         for (String f : filter.split(",")) {
254           String[] filterTuple = f.split(":",2);
255           if (filterTuple.length < 2) {
256             logger.debug("Filter {} not valid: {}", filterTuple[0], filter);
257             continue;
258           }
259           String name = filterTuple[0];
260 
261           String value;
262           if (!requestedVersion.isSmallerThan(VERSION_1_1_0)) {
263             // MH-13038 - 1.1.0 and higher support semi-colons in values
264             value = f.substring(name.length() + 1);
265           } else {
266             value = filterTuple[1];
267           }
268 
269           if ("managedAcl".equals(name)) {
270             query.withManagedAcl(value);
271           } else if ("contributors".equals(name)) {
272             query.withContributor(value);
273           } else if ("CreationDate".equals(name)) {
274             try {
275               Tuple<Date, Date> fromAndToCreationRange = getFromAndToCreationRange(value.split("/")[0],
276                       value.split("/")[1]);
277               query.withCreatedFrom(fromAndToCreationRange.getA());
278               query.withCreatedTo(fromAndToCreationRange.getB());
279             } catch (IllegalArgumentException e) {
280               return RestUtil.R.badRequest(e.getMessage());
281             } catch (ArrayIndexOutOfBoundsException e) {
282               String dateErrorMsg = String.format("Filter Series API error: Malformed date period. "
283                   + "Correct UTC time period format: yyyy-MM-ddTHH:mm:ssZ/yyyy-MM-ddTHH:mm:ssZ, "
284                   + "stated date period string: \"%s\"", value);
285               logger.warn(dateErrorMsg);
286               return RestUtil.R.badRequest(dateErrorMsg);
287             }
288           } else if ("Creator".equals(name)) {
289             query.withCreator(value);
290           } else if ("textFilter".equals(name)) {
291             query.withText(value);
292           } else if ("language".equals(name)) {
293             query.withLanguage(value);
294           } else if ("license".equals(name)) {
295             query.withLicense(value);
296           } else if ("organizers".equals(name)) {
297             query.withOrganizer(value);
298           } else if ("subject".equals(name)) {
299             query.withSubject(value);
300           } else if ("title".equals(name)) {
301             query.withTitle(value);
302           } else if (!requestedVersion.isSmallerThan(VERSION_1_1_0)) {
303             // additional filters only available with Version 1.1.0 or higher
304             if ("identifier".equals(name)) {
305               query.withIdentifier(value);
306             } else if ("description".equals(name)) {
307               query.withDescription(value);
308             } else if ("creator".equals(name)) {
309               query.withCreator(value);
310             } else if ("publishers".equals(name)) {
311               query.withPublisher(value);
312             } else if ("rightsholder".equals(name)) {
313               query.withRightsHolder(value);
314             } else {
315               logger.warn("Unknown filter criteria {}", name);
316               return Response.status(SC_BAD_REQUEST).build();
317             }
318           }
319         }
320       }
321 
322       if (optSort.isPresent()) {
323         ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(optSort.get());
324         for (SortCriterion criterion : sortCriteria) {
325 
326           switch (criterion.getFieldName()) {
327             case SeriesIndexSchema.TITLE:
328               query.sortByTitle(criterion.getOrder());
329               break;
330             case SeriesIndexSchema.CONTRIBUTORS:
331               query.sortByContributors(criterion.getOrder());
332               break;
333             case SeriesIndexSchema.CREATOR:
334               query.sortByOrganizers(criterion.getOrder());
335               break;
336             case EventIndexSchema.CREATED:
337               query.sortByCreatedDateTime(criterion.getOrder());
338               break;
339             default:
340               logger.info("Unknown sort criteria {}", criterion.getFieldName());
341               return Response.status(SC_BAD_REQUEST).build();
342           }
343         }
344       }
345 
346       if (onlyWithWriteAccess != null && onlyWithWriteAccess) {
347         query.withoutActions();
348         query.withAction(Permissions.Action.WRITE);
349       }
350 
351       logger.trace("Using Query: " + query.toString());
352 
353       SearchResult<Series> result = elasticsearchIndex.getByQuery(query);
354       final boolean includeAcl = (withAcl != null && withAcl);
355       return queryResultToJson(result, includeAcl, requestedVersion);
356 
357     } catch (Exception e) {
358       logger.warn("Could not perform search query", e);
359       throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
360     }
361   }
362 
363   private Response queryResultToJson(SearchResult<Series> result, boolean includeAcl, ApiVersion requestedVersion) {
364     JsonArray seriesArray = new JsonArray();
365 
366     for (SearchResultItem<Series> item : result.getItems()) {
367       final Series s = item.getSource();
368       JsonArray subjects;
369       if (s.getSubject() == null) {
370         subjects = new JsonArray();
371       } else {
372         subjects = splitSubjectIntoArray(s.getSubject());
373       }
374 
375       Date createdDate = s.getCreatedDateTime();
376       JsonObject seriesJson = new JsonObject();
377 
378       seriesJson.addProperty("identifier", s.getIdentifier());
379       seriesJson.addProperty("title", s.getTitle());
380       seriesJson.addProperty("creator", safeString(s.getCreator()));
381       seriesJson.addProperty("created", createdDate != null ? toUTC(createdDate.getTime()) : "");
382       seriesJson.add("subjects", subjects);
383 
384       seriesJson.add("contributors", collectionToJsonArray(s.getContributors()));
385       seriesJson.add("organizers", collectionToJsonArray(s.getOrganizers()));
386       seriesJson.add("publishers", collectionToJsonArray(s.getPublishers()));
387 
388       if (!requestedVersion.isSmallerThan(VERSION_1_1_0)) {
389         seriesJson.addProperty("description", safeString(s.getDescription()));
390         seriesJson.addProperty("language", safeString(s.getLanguage()));
391         seriesJson.addProperty("license", safeString(s.getLicense()));
392         seriesJson.addProperty("rightsholder", safeString(s.getRightsHolder()));
393 
394         if (includeAcl) {
395           AccessControlList acl = getAclFromSeries(s);
396           seriesJson.add("acl", AclUtils.serializeAclToJson(acl));
397         }
398       }
399 
400       seriesArray.add(seriesJson);
401     }
402 
403     return ApiResponseBuilder.Json.ok(requestedVersion, seriesArray);
404   }
405 
406   /**
407    * Get an {@link AccessControlList} from a {@link Series}.
408    *
409    * @param series
410    *          The {@link Series} to get the ACL from.
411    * @return The {@link AccessControlList} stored in the {@link Series}
412    */
413   private static AccessControlList getAclFromSeries(Series series) {
414     AccessControlList activeAcl = new AccessControlList();
415     try {
416       if (series.getAccessPolicy() != null) {
417         activeAcl = AccessControlParser.parseAcl(series.getAccessPolicy());
418       }
419     } catch (Exception e) {
420       logger.error("Unable to parse access policy", e);
421     }
422     return activeAcl;
423   }
424 
425   @GET
426   @Path("{seriesId}")
427   @RestQuery(
428       name = "getseries",
429       description = "Returns a single series.",
430       returnDescription = "",
431       pathParameters = {
432           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
433       },
434       restParameters = {
435           @RestParameter(name = "withacl", isRequired = false, type = RestParameter.Type.BOOLEAN,
436                              description = "Whether the acl should be included in the response.")
437       },
438       responses = {
439           @RestResponse(description = "The series is returned.", responseCode = HttpServletResponse.SC_OK),
440           @RestResponse(description = "The specified series does not exist.",
441               responseCode = HttpServletResponse.SC_NOT_FOUND), })
442   public Response getSeries(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id,
443                             @QueryParam("withacl") Boolean withAcl)
444           throws Exception {
445     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
446     if (requestedVersion.isSmallerThan(VERSION_1_5_0)) {
447       // withAcl was added for version 1.5.0 and should be ignored for smaller versions.
448       withAcl = false;
449     }
450 
451     Optional<Series> optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(),
452         securityService.getUser());
453     if (optSeries.isPresent()) {
454       final Series s = optSeries.get();
455       JsonArray subjects;
456       if (s.getSubject() == null) {
457         subjects = new JsonArray();
458       } else {
459         subjects = splitSubjectIntoArray(s.getSubject());
460       }
461       Date createdDate = s.getCreatedDateTime();
462 
463       JsonObject responseContent = new JsonObject();
464 
465       // Common fields
466       responseContent.addProperty("identifier", s.getIdentifier());
467       responseContent.addProperty("title", s.getTitle());
468       responseContent.addProperty("description", safeString(s.getDescription()));
469       responseContent.addProperty("creator", safeString(s.getCreator()));
470       responseContent.add("subjects", subjects);
471       responseContent.addProperty("organization", s.getOrganization());
472       responseContent.addProperty("created", createdDate != null ? toUTC(createdDate.getTime()) : "");
473       responseContent.add("contributors", collectionToJsonArray(s.getContributors()));
474       responseContent.add("organizers", collectionToJsonArray(s.getOrganizers()));
475       responseContent.addProperty("opt_out", false);
476       responseContent.add("publishers", collectionToJsonArray(s.getPublishers()));
477 
478       if (!requestedVersion.isSmallerThan(VERSION_1_1_0)) {
479         responseContent.addProperty("language", safeString(s.getLanguage()));
480         responseContent.addProperty("license", safeString(s.getLicense()));
481         responseContent.addProperty("rightsholder", safeString(s.getRightsHolder()));
482 
483         if (withAcl != null && withAcl) {
484           AccessControlList acl = getAclFromSeries(s);
485           JsonArray aclJsonArray = AclUtils.serializeAclToJson(acl);
486           responseContent.add("acl", aclJsonArray);
487         }
488       }
489 
490       return ApiResponseBuilder.Json.ok(requestedVersion, responseContent);
491     }
492     return ApiResponseBuilder.notFound("Cannot find an series with id '%s'.", id);
493   }
494 
495   private JsonArray splitSubjectIntoArray(final String subject) {
496     JsonArray array = new JsonArray();
497     if (subject != null && !subject.trim().isEmpty()) {
498       for (String part : subject.split(",")) {
499         array.add(new JsonPrimitive(part.trim()));
500       }
501     }
502     return array;
503   }
504 
505   @GET
506   @Path("{seriesId}/metadata")
507   @RestQuery(
508       name = "getseriesmetadata",
509       description = "Returns a series' metadata of all types or returns a series' metadata collection of the given "
510           + "type when the query string parameter type is specified. For each metadata catalog there is a unique "
511           + "property called the flavor such as dublincore/series so the type in this example would be "
512           + "'dublincore/series'",
513       returnDescription = "",
514       pathParameters = {
515           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
516       },
517       restParameters = {
518           @RestParameter(name = "type", isRequired = false, description = "The type of metadata to return",
519               type = STRING)
520       },
521       responses = {
522           @RestResponse(description = "The series' metadata are returned.", responseCode = HttpServletResponse.SC_OK),
523           @RestResponse(description = "The specified series does not exist.",
524               responseCode = HttpServletResponse.SC_NOT_FOUND)
525       })
526   public Response getSeriesMetadata(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id,
527           @QueryParam("type") String type) throws Exception {
528     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
529     if (StringUtils.trimToNull(type) == null) {
530       return getAllMetadata(id, requestedVersion);
531     } else {
532       return getMetadataByType(id, type, requestedVersion);
533     }
534   }
535 
536   private Response getAllMetadata(String id, ApiVersion requestedVersion) throws SearchIndexException {
537     Optional<Series> optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(),
538         securityService.getUser());
539     if (optSeries.isEmpty()) {
540       return ApiResponseBuilder.notFound("Cannot find a series with id '%s'.", id);
541     }
542 
543     MetadataList metadataList = new MetadataList();
544     List<SeriesCatalogUIAdapter> catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
545     catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
546     for (SeriesCatalogUIAdapter adapter : catalogUIAdapters) {
547       final Optional<DublinCoreMetadataCollection> optSeriesMetadata = adapter.getFields(id);
548       if (optSeriesMetadata.isPresent()) {
549         metadataList.add(adapter.getFlavor().toString(), adapter.getUITitle(), optSeriesMetadata.get());
550       }
551     }
552     DublinCoreMetadataCollection collection = getSeriesMetadata(optSeries.get());
553     ExternalMetadataUtils.changeSubjectToSubjects(collection);
554     metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), collection);
555     return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.listToJson(metadataList, false));
556   }
557 
558   private Response getMetadataByType(String id, String type, ApiVersion requestedVersion) throws SearchIndexException {
559     Optional<Series> optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(),
560         securityService.getUser());
561     if (optSeries.isEmpty()) {
562       return ApiResponseBuilder.notFound("Cannot find a series with id '%s'.", id);
563     }
564 
565     // Try the main catalog first as we load it from the index.
566     if (typeMatchesSeriesCatalogUIAdapter(type, indexService.getCommonSeriesCatalogUIAdapter())) {
567       DublinCoreMetadataCollection collection = getSeriesMetadata(optSeries.get());
568       ExternalMetadataUtils.changeSubjectToSubjects(collection);
569       return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.collectionToJson(collection, false));
570     }
571 
572     // Try the other catalogs
573     List<SeriesCatalogUIAdapter> catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
574     catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
575 
576     for (SeriesCatalogUIAdapter adapter : catalogUIAdapters) {
577       if (typeMatchesSeriesCatalogUIAdapter(type, adapter)) {
578         final Optional<DublinCoreMetadataCollection> optSeriesMetadata = adapter.getFields(id);
579         if (optSeriesMetadata.isPresent()) {
580           return ApiResponseBuilder.Json.ok(requestedVersion, MetadataJson.collectionToJson(optSeriesMetadata.get(),
581               true));
582         }
583       }
584     }
585     return ApiResponseBuilder.notFound("Cannot find a catalog with type '%s' for series with id '%s'.", type, id);
586   }
587 
588   /**
589    * Loads the metadata for the given series
590    *
591    * @param series
592    *          the source {@link Series}
593    * @return a {@link DublinCoreMetadataCollection} instance with all the series metadata
594    */
595   private DublinCoreMetadataCollection getSeriesMetadata(Series series) {
596     DublinCoreMetadataCollection metadata = indexService.getCommonSeriesCatalogUIAdapter().getRawFields();
597 
598     MetadataField title = metadata.getOutputFields().get(DublinCore.PROPERTY_TITLE.getLocalName());
599     metadata.removeField(title);
600     MetadataField newTitle = new MetadataField(title);
601     newTitle.setValue(series.getTitle());
602     metadata.addField(newTitle);
603 
604     MetadataField subject = metadata.getOutputFields().get(DublinCore.PROPERTY_SUBJECT.getLocalName());
605     metadata.removeField(subject);
606     MetadataField newSubject = new MetadataField(subject);
607     newSubject.setValue(series.getSubject());
608     metadata.addField(newSubject);
609 
610     MetadataField description = metadata.getOutputFields().get(DublinCore.PROPERTY_DESCRIPTION.getLocalName());
611     metadata.removeField(description);
612     MetadataField newDescription = new MetadataField(description);
613     newDescription.setValue(series.getDescription());
614     metadata.addField(newDescription);
615 
616     MetadataField language = metadata.getOutputFields().get(DublinCore.PROPERTY_LANGUAGE.getLocalName());
617     metadata.removeField(language);
618     MetadataField newLanguage = new MetadataField(language);
619     newLanguage.setValue(series.getLanguage());
620     metadata.addField(newLanguage);
621 
622     MetadataField rightsHolder = metadata.getOutputFields().get(DublinCore.PROPERTY_RIGHTS_HOLDER.getLocalName());
623     metadata.removeField(rightsHolder);
624     MetadataField newRightsHolder = new MetadataField(rightsHolder);
625     newRightsHolder.setValue(series.getRightsHolder());
626     metadata.addField(newRightsHolder);
627 
628     MetadataField license = metadata.getOutputFields().get(DublinCore.PROPERTY_LICENSE.getLocalName());
629     metadata.removeField(license);
630     MetadataField newLicense = new MetadataField(license);
631     newLicense.setValue(series.getLicense());
632     metadata.addField(newLicense);
633 
634     MetadataField organizers = metadata.getOutputFields().get(DublinCore.PROPERTY_CREATOR.getLocalName());
635     metadata.removeField(organizers);
636     MetadataField newOrganizers = new MetadataField(organizers);
637     newOrganizers.setValue(StringUtils.join(series.getOrganizers(), ", "));
638     metadata.addField(newOrganizers);
639 
640     MetadataField contributors = metadata.getOutputFields().get(DublinCore.PROPERTY_CONTRIBUTOR.getLocalName());
641     metadata.removeField(contributors);
642     MetadataField newContributors = new MetadataField(contributors);
643     newContributors.setValue(StringUtils.join(series.getContributors(), ", "));
644     metadata.addField(newContributors);
645 
646     MetadataField publishers = metadata.getOutputFields().get(DublinCore.PROPERTY_PUBLISHER.getLocalName());
647     metadata.removeField(publishers);
648     MetadataField newPublishers = new MetadataField(publishers);
649     newPublishers.setValue(StringUtils.join(series.getPublishers(), ", "));
650     metadata.addField(newPublishers);
651 
652     // Admin UI only field
653     MetadataField createdBy = new MetadataField(
654             "createdBy",
655             null,
656             "EVENTS.SERIES.DETAILS.METADATA.CREATED_BY",
657             true,
658             false,
659             null,
660             null,
661             MetadataField.Type.TEXT,
662             null,
663             null,
664             CREATED_BY_UI_ORDER,
665             null,
666             null,
667             null,
668             null);
669     createdBy.setValue(series.getCreator());
670     metadata.addField(createdBy);
671 
672     MetadataField uid = metadata.getOutputFields().get(DublinCore.PROPERTY_IDENTIFIER.getLocalName());
673     metadata.removeField(uid);
674     MetadataField newUID = new MetadataField(uid);
675     newUID.setValue(series.getIdentifier());
676     metadata.addField(newUID);
677 
678     ExternalMetadataUtils.removeCollectionList(metadata);
679 
680     return metadata;
681   }
682 
683   /**
684    * Checks if a flavor type matches a series catalog's flavor type.
685    *
686    * @param type
687    *          The flavor type to compare against the catalog's flavor
688    * @param catalog
689    *          The catalog to check if it matches the flavor.
690    * @return True if it matches.
691    */
692   private boolean typeMatchesSeriesCatalogUIAdapter(String type, SeriesCatalogUIAdapter catalog) {
693     if (StringUtils.trimToNull(type) == null) {
694       return false;
695     }
696     MediaPackageElementFlavor catalogFlavor = MediaPackageElementFlavor.parseFlavor(catalog.getFlavor().toString());
697     try {
698       MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(type);
699       return flavor.equals(catalogFlavor);
700     } catch (IllegalArgumentException e) {
701       return false;
702     }
703   }
704 
705   private Optional<MediaPackageElementFlavor> getFlavor(String flavorString) {
706     try {
707       MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(flavorString);
708       return Optional.of(flavor);
709     } catch (IllegalArgumentException e) {
710       return Optional.empty();
711     }
712   }
713 
714   @PUT
715   @Path("{seriesId}/metadata")
716   @RestQuery(
717       name = "updateseriesmetadata",
718       description = "Update a series' metadata of the given type. For a metadata catalog there is the flavor such as "
719           + "'dublincore/series' and this is the unique type.",
720       returnDescription = "",
721       pathParameters = {
722           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
723       },
724       restParameters = {
725           @RestParameter(name = "type", isRequired = true, description = "The type of metadata to update",
726               type = STRING),
727           @RestParameter(name = "metadata", description = "Series metadata as Form param", isRequired = true,
728               type = STRING)
729       },
730       responses = {
731           @RestResponse(description = "The series' metadata have been updated.",
732               responseCode = HttpServletResponse.SC_OK),
733           @RestResponse(description = "The request is invalid or inconsistent.",
734               responseCode = HttpServletResponse.SC_BAD_REQUEST),
735           @RestResponse(description = "The specified series does not exist.",
736               responseCode = HttpServletResponse.SC_NOT_FOUND)
737       })
738   public Response updateSeriesMetadata(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id,
739           @QueryParam("type") String type, @FormParam("metadata") String metadataJSON) throws Exception {
740     if (StringUtils.trimToNull(metadataJSON) == null) {
741       return RestUtil.R.badRequest("Unable to update metadata for series as the metadata provided is empty.");
742     }
743     Map<String, String> updatedFields;
744     try {
745       updatedFields = RequestUtils.getKeyValueMap(metadataJSON);
746     } catch (ParseException e) {
747       logger.debug("Unable to update series '{}' with metadata type '{}' and content '{}'", id, type, metadataJSON, e);
748       return RestUtil.R.badRequest(String.format("Unable to parse metadata fields as json from '%s' because '%s'",
749               metadataJSON, e.getMessage()));
750     } catch (IllegalArgumentException e) {
751       return RestUtil.R.badRequest(e.getMessage());
752     }
753 
754     if (updatedFields == null || updatedFields.size() == 0) {
755       return RestUtil.R.badRequest(
756               String.format("Unable to parse metadata fields as json from '%s' because there were no fields to update.",
757                       metadataJSON));
758     }
759 
760     Optional<DublinCoreMetadataCollection> optCollection = Optional.empty();
761     SeriesCatalogUIAdapter adapter = null;
762 
763     Optional<Series> optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(),
764         securityService.getUser());
765     if (optSeries.isEmpty()) {
766       return ApiResponseBuilder.notFound("Cannot find a series with id '%s'.", id);
767     }
768 
769     MetadataList metadataList = new MetadataList();
770 
771     // Try the main catalog first as we load it from the index.
772     if (typeMatchesSeriesCatalogUIAdapter(type, indexService.getCommonSeriesCatalogUIAdapter())) {
773       optCollection = Optional.of(getSeriesMetadata(optSeries.get()));
774       adapter = indexService.getCommonSeriesCatalogUIAdapter();
775     } else {
776       metadataList.add(indexService.getCommonSeriesCatalogUIAdapter(), getSeriesMetadata(optSeries.get()));
777     }
778 
779     // Try the other catalogs
780     List<SeriesCatalogUIAdapter> catalogUIAdapters = indexService.getSeriesCatalogUIAdapters();
781     catalogUIAdapters.remove(indexService.getCommonSeriesCatalogUIAdapter());
782     if (catalogUIAdapters.size() > 0) {
783       for (SeriesCatalogUIAdapter catalogUIAdapter : catalogUIAdapters) {
784         if (typeMatchesSeriesCatalogUIAdapter(type, catalogUIAdapter)) {
785           optCollection = catalogUIAdapter.getFields(id);
786           adapter = catalogUIAdapter;
787         } else {
788           Optional<DublinCoreMetadataCollection> current = catalogUIAdapter.getFields(id);
789           if (current.isPresent()) {
790             metadataList.add(catalogUIAdapter, current.get());
791           }
792         }
793       }
794     }
795 
796     if (optCollection.isEmpty()) {
797       return ApiResponseBuilder.notFound("Cannot find a catalog with type '%s' for series with id '%s'.", type, id);
798     }
799 
800     DublinCoreMetadataCollection collection = optCollection.get();
801 
802     for (String key : updatedFields.keySet()) {
803       MetadataField field = collection.getOutputFields().get(key);
804       if (field == null) {
805         return ApiResponseBuilder.notFound(
806                 "Cannot find a metadata field with id '%s' from event with id '%s' and the metadata type '%s'.", key,
807                 id, type);
808       } else if (field.isRequired() && StringUtils.isBlank(updatedFields.get(key))) {
809         return R.badRequest(String.format(
810                 "The series metadata field with id '%s' and the metadata type '%s' is required and can not be empty!.",
811                 key, type));
812       }
813       collection.removeField(field);
814       collection.addField(MetadataJson.copyWithDifferentJsonValue(field, updatedFields.get(key)));
815     }
816 
817     metadataList.add(adapter, collection);
818     indexService.updateAllSeriesMetadata(id, metadataList, elasticsearchIndex);
819     return ApiResponseBuilder.Json.ok(acceptHeader, "");
820   }
821 
822   @DELETE
823   @Path("{seriesId}/metadata")
824   @RestQuery(
825       name = "deleteseriesmetadata",
826       description = "Deletes a series' metadata catalog of the given type. All fields and values of that catalog will "
827           + "be deleted.",
828       returnDescription = "",
829       pathParameters = {
830           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
831       },
832       restParameters = {
833           @RestParameter(name = "type", isRequired = true, description = "The type of metadata to delete",
834               type = STRING)
835       },
836       responses = {
837           @RestResponse(description = "The metadata have been deleted.",
838               responseCode = HttpServletResponse.SC_NO_CONTENT),
839           @RestResponse(description = "The main metadata catalog dublincore/series cannot be deleted as it has "
840               + "mandatory fields.", responseCode = HttpServletResponse.SC_FORBIDDEN),
841           @RestResponse(description = "The specified series does not exist.",
842               responseCode = HttpServletResponse.SC_NOT_FOUND)
843       })
844   public Response deleteSeriesMetadataByType(@HeaderParam("Accept") String acceptHeader,
845           @PathParam("seriesId") String id, @QueryParam("type") String type) throws Exception {
846     if (StringUtils.trimToNull(type) == null) {
847       return RestUtil.R
848               .badRequest(String.format("A type of catalog needs to be specified for series '%s' to delete it.", id));
849     }
850 
851     Optional<MediaPackageElementFlavor> flavor = getFlavor(type);
852 
853     if (flavor.isEmpty()) {
854       return RestUtil.R.badRequest(
855               String.format("Unable to parse flavor '%s' it should look something like dublincore/series.", type));
856     }
857 
858     if (typeMatchesSeriesCatalogUIAdapter(type, indexService.getCommonSeriesCatalogUIAdapter())) {
859       return Response
860               .status(Status.FORBIDDEN).entity(String
861                       .format("Unable to delete mandatory metadata catalog with type '%s' for series '%s'", type, id))
862               .build();
863     }
864 
865     Optional<Series> optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(),
866         securityService.getUser());
867     if (optSeries.isEmpty()) {
868       return ApiResponseBuilder.notFound("Cannot find a series with id '%s'.", id);
869     }
870 
871     try {
872       indexService.removeCatalogByFlavor(optSeries.get(), MediaPackageElementFlavor.parseFlavor(type));
873     } catch (NotFoundException e) {
874       return ApiResponseBuilder.notFound(e.getMessage());
875     }
876     return Response.noContent().build();
877   }
878 
879   @GET
880   @Path("{seriesId}/acl")
881   @RestQuery(
882       name = "getseriesacl",
883       description = "Returns a series' access policy.",
884       returnDescription = "",
885       pathParameters = {
886           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
887       },
888       responses = {
889           @RestResponse(description = "The series' access policy is returned.",
890               responseCode = HttpServletResponse.SC_OK),
891           @RestResponse(description = "The specified series does not exist.",
892               responseCode = HttpServletResponse.SC_NOT_FOUND)
893       })
894   public Response getSeriesAcl(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id)
895           throws Exception {
896     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
897     JSONParser parser = new JSONParser();
898     Optional<Series> optSeries = elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(),
899         securityService.getUser());
900     if (optSeries.isPresent()) {
901       Series series = optSeries.get();
902       // The ACL is stored as JSON string in the index. Parse it and extract the part we want to have in the API.
903       if (series.getAccessPolicy() == null) {
904         return ApiResponseBuilder.notFound("Acl for series with id '%s' is not defined.", id);
905       }
906       JSONObject acl = (JSONObject) parser.parse(series.getAccessPolicy());
907 
908       if (!((JSONObject) acl.get("acl")).containsKey("ace")) {
909         return ApiResponseBuilder.notFound("Cannot find acl for series with id '%s'.", id);
910       } else {
911         return ApiResponseBuilder.Json.ok(requestedVersion, ((JSONArray) ((JSONObject) acl.get("acl")).get("ace"))
912             .toJSONString());
913       }
914     }
915 
916     return ApiResponseBuilder.notFound("Cannot find an series with id '%s'.", id);
917   }
918 
919   @GET
920   @Path("{seriesId}/properties")
921   @RestQuery(
922       name = "getseriesproperties",
923       description = "Returns a series' properties",
924       returnDescription = "",
925       pathParameters = {
926           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
927       },
928       responses = {
929           @RestResponse(description = "The series' properties are returned.", responseCode = HttpServletResponse.SC_OK),
930           @RestResponse(description = "The specified series does not exist.",
931               responseCode = HttpServletResponse.SC_NOT_FOUND)
932       })
933   public Response getSeriesProperties(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id)
934           throws Exception {
935     if (elasticsearchIndex.getSeries(id, securityService.getOrganization().getId(),
936         securityService.getUser()).isPresent()) {
937       final Map<String, String> properties = seriesService.getSeriesProperties(id);
938 
939       JsonObject json = new JsonObject();
940       for (Entry<String, String> entry : properties.entrySet()) {
941         json.addProperty(entry.getKey(), safeString(entry.getValue()));
942       }
943 
944       return ApiResponseBuilder.Json.ok(acceptHeader, json);
945     } else {
946       return ApiResponseBuilder.notFound("Cannot find a series with id '%s'.", id);
947     }
948   }
949 
950   @DELETE
951   @Path("{seriesId}")
952   @RestQuery(
953       name = "deleteseries",
954       description = "Deletes a series.",
955       returnDescription = "",
956       pathParameters = {
957           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
958       },
959       responses = {
960           @RestResponse(description = "The series has been deleted.", responseCode = HttpServletResponse.SC_NO_CONTENT),
961           @RestResponse(description = "The specified series does not exist.",
962               responseCode = HttpServletResponse.SC_NOT_FOUND)
963       })
964   public Response deleteSeries(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String id)
965           throws NotFoundException {
966     try {
967       indexService.removeSeries(id);
968       return Response.noContent().build();
969     } catch (NotFoundException e) {
970       return ApiResponseBuilder.notFound("Cannot find a series with id '%s'.", id);
971     } catch (Exception e) {
972       logger.error("Unable to delete the series '{}' due to", id, e);
973       return Response.serverError().build();
974     }
975   }
976 
977   @PUT
978   @Path("{seriesId}")
979   @RestQuery(
980       name = "updateallseriesmetadata",
981       description = "Update all series metadata.",
982       returnDescription = "",
983       pathParameters = {
984           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
985       },
986       restParameters = {
987           @RestParameter(name = "metadata", description = "Series metadata as Form param", isRequired = true,
988               type = STRING)
989       },
990       responses = {
991           @RestResponse(description = "The series' metadata have been updated.",
992               responseCode = HttpServletResponse.SC_OK),
993           @RestResponse(description = "The request is invalid or inconsistent.",
994               responseCode = HttpServletResponse.SC_BAD_REQUEST),
995           @RestResponse(description = "The specified series does not exist.",
996               responseCode = HttpServletResponse.SC_NOT_FOUND)
997       })
998   public Response updateSeriesMetadata(@HeaderParam("Accept") String acceptHeader,
999       @PathParam("seriesId") String seriesID, @FormParam("metadata") String metadataJSON)
1000           throws UnauthorizedException, NotFoundException, SearchIndexException {
1001     try {
1002       MetadataList metadataList = indexService.updateAllSeriesMetadata(seriesID, metadataJSON, elasticsearchIndex);
1003       return ApiResponseBuilder.Json.ok(acceptHeader, MetadataJson.listToJson(metadataList, true));
1004     } catch (IllegalArgumentException e) {
1005       logger.debug("Unable to update series '{}' with metadata '{}'", seriesID, metadataJSON, e);
1006       return RestUtil.R.badRequest(e.getMessage());
1007     } catch (IndexServiceException e) {
1008       logger.error("Unable to update series '{}' with metadata '{}'", seriesID, metadataJSON, e);
1009       return RestUtil.R.serverError();
1010     }
1011   }
1012 
1013   @POST
1014   @Path("")
1015   @RestQuery(
1016       name = "createseries",
1017       description = "Creates a series.",
1018       returnDescription = "",
1019       restParameters = {
1020           @RestParameter(name = "metadata", isRequired = true, description = "Series metadata", type = STRING),
1021           @RestParameter(name = "acl", description = "A collection of roles with their possible action",
1022               isRequired = true, type = STRING),
1023           @RestParameter(name = "theme", description = "The theme ID to be applied to the series", isRequired = false,
1024               type = STRING)
1025       },
1026       responses = {
1027           @RestResponse(description = "A new series is created and its identifier is returned in the Location header.",
1028               responseCode = HttpServletResponse.SC_CREATED),
1029           @RestResponse(description = "The request is invalid or inconsistent..",
1030               responseCode = HttpServletResponse.SC_BAD_REQUEST),
1031           @RestResponse(description = "The user doesn't have the rights to create the series.",
1032               responseCode = HttpServletResponse.SC_UNAUTHORIZED)
1033       })
1034   public Response createNewSeries(@HeaderParam("Accept") String acceptHeader,
1035           @FormParam("metadata") String metadataParam, @FormParam("acl") String aclParam,
1036           @FormParam("theme") String themeIdParam) throws UnauthorizedException, NotFoundException {
1037     if (isBlank(metadataParam)) {
1038       return R.badRequest("Required parameter 'metadata' is missing or invalid");
1039     }
1040 
1041     if (isBlank(aclParam)) {
1042       return R.badRequest("Required parameter 'acl' is missing or invalid");
1043     }
1044 
1045     MetadataList metadataList;
1046     try {
1047       metadataList = deserializeMetadataList(metadataParam);
1048     } catch (ParseException e) {
1049       logger.debug("Unable to parse series metadata '{}'", metadataParam, e);
1050       return R.badRequest(String.format("Unable to parse metadata because '%s'", e.getMessage()));
1051     } catch (NotFoundException e) {
1052       // One of the metadata fields could not be found in the catalogs or one of the catalogs cannot be found.
1053       return R.badRequest(e.getMessage());
1054     } catch (IllegalArgumentException e) {
1055       logger.debug("Unable to create series with metadata '{}'", metadataParam, e);
1056       return R.badRequest(e.getMessage());
1057     }
1058     Map<String, String> options = new TreeMap<>();
1059     Optional<Long> optThemeId = Optional.empty();
1060     if (StringUtils.trimToNull(themeIdParam) != null) {
1061       try {
1062         Long themeId = Long.parseLong(themeIdParam);
1063         optThemeId = Optional.of(themeId);
1064       } catch (NumberFormatException e) {
1065         return R.badRequest(String.format("Unable to parse the theme id '%s' into a number", themeIdParam));
1066       }
1067     }
1068     AccessControlList acl;
1069     try {
1070       acl = AclUtils.deserializeJsonToAcl(aclParam, false);
1071     } catch (ParseException e) {
1072       logger.debug("Unable to parse acl '{}'", aclParam, e);
1073       return R.badRequest(String.format("Unable to parse acl '%s' because '%s'", aclParam, e.getMessage()));
1074     } catch (IllegalArgumentException e) {
1075       logger.debug("Unable to create new series with acl '{}'", aclParam, e);
1076       return R.badRequest(e.getMessage());
1077     }
1078 
1079     try {
1080       String seriesId = indexService.createSeries(metadataList, options, Optional.of(acl), optThemeId);
1081       JsonObject json = new JsonObject();
1082       json.addProperty("identifier", safeString(seriesId));
1083       return ApiResponseBuilder.Json.created(acceptHeader, URI.create(getSeriesUrl(seriesId)), json);
1084     } catch (IndexServiceException e) {
1085       logger.error("Unable to create series with metadata '{}', acl '{}', theme '{}'",
1086               metadataParam, aclParam, themeIdParam, e);
1087       throw new WebApplicationException(e, Status.INTERNAL_SERVER_ERROR);
1088     }
1089   }
1090 
1091   /**
1092    * Change the simplified fields of key values provided to the external api into a {@link MetadataList}.
1093    *
1094    * @param json
1095    *          The json string that contains an array of metadata field lists for the different catalogs.
1096    * @return A {@link MetadataList} with the fields populated with the values provided.
1097    * @throws ParseException
1098    *           Thrown if unable to parse the json string.
1099    * @throws NotFoundException
1100    *           Thrown if unable to find the catalog or field that the json refers to.
1101    */
1102   protected MetadataList deserializeMetadataList(String json) throws ParseException, NotFoundException {
1103     MetadataList metadataList = new MetadataList();
1104     JSONParser parser = new JSONParser();
1105     JSONArray jsonCatalogs = (JSONArray) parser.parse(json);
1106     for (int i = 0; i < jsonCatalogs.size(); i++) {
1107       JSONObject catalog = (JSONObject) jsonCatalogs.get(i);
1108       if (catalog.get("flavor") == null || StringUtils.isBlank(catalog.get("flavor").toString())) {
1109         throw new IllegalArgumentException(
1110                 "Unable to create new series as no flavor was given for one of the metadata collections");
1111       }
1112       String flavorString = catalog.get("flavor").toString();
1113 
1114       MediaPackageElementFlavor flavor = MediaPackageElementFlavor.parseFlavor(flavorString);
1115 
1116       DublinCoreMetadataCollection collection = null;
1117       SeriesCatalogUIAdapter adapter = null;
1118       for (SeriesCatalogUIAdapter seriesCatalogUIAdapter : indexService.getSeriesCatalogUIAdapters()) {
1119         MediaPackageElementFlavor catalogFlavor = MediaPackageElementFlavor
1120                 .parseFlavor(seriesCatalogUIAdapter.getFlavor().toString());
1121         if (catalogFlavor.equals(flavor)) {
1122           adapter = seriesCatalogUIAdapter;
1123           collection = seriesCatalogUIAdapter.getRawFields();
1124         }
1125       }
1126 
1127       if (collection == null) {
1128         throw new IllegalArgumentException(
1129                 String.format("Unable to find an SeriesCatalogUIAdapter with Flavor '%s'", flavorString));
1130       }
1131 
1132       String fieldsJson = catalog.get("fields").toString();
1133       if (StringUtils.trimToNull(fieldsJson) != null) {
1134         Map<String, String> fields = RequestUtils.getKeyValueMap(fieldsJson);
1135         for (String key : fields.keySet()) {
1136           if ("subjects".equals(key)) {
1137             MetadataField field = collection.getOutputFields().get("subject");
1138             if (field == null) {
1139               throw new NotFoundException(String.format(
1140                       "Cannot find a metadata field with id '%s' from Catalog with Flavor '%s'.", key, flavorString));
1141             }
1142             collection.removeField(field);
1143             try {
1144               JSONArray subjects = (JSONArray) parser.parse(fields.get(key));
1145               collection.addField(
1146                       MetadataJson.copyWithDifferentJsonValue(field, StringUtils.join(subjects.iterator(), ",")));
1147             } catch (ParseException e) {
1148               throw new IllegalArgumentException(
1149                       String.format("Unable to parse the 'subjects' metadata array field because: %s", e.toString()));
1150             }
1151           } else {
1152             MetadataField field = collection.getOutputFields().get(key);
1153             if (field == null) {
1154               throw new NotFoundException(String.format(
1155                       "Cannot find a metadata field with id '%s' from Catalog with Flavor '%s'.", key, flavorString));
1156             }
1157             collection.removeField(field);
1158             collection.addField(MetadataJson.copyWithDifferentJsonValue(field, fields.get(key)));
1159           }
1160         }
1161       }
1162       metadataList.add(adapter, collection);
1163     }
1164     return metadataList;
1165   }
1166 
1167   @PUT
1168   @Path("{seriesId}/acl")
1169   @RestQuery(
1170       name = "updateseriesacl",
1171       description = "Updates a series' access policy.",
1172       returnDescription = "",
1173       pathParameters = {
1174           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
1175       },
1176       restParameters = {
1177           @RestParameter(name = "acl", isRequired = true, description = "Access policy", type = STRING),
1178           @RestParameter(name = "override", isRequired = false, description = "If true the series ACL will take "
1179               + "precedence over any existing episode ACL", type = STRING)
1180       },
1181       responses = {
1182           @RestResponse(description = "The access control list for the specified series is updated.",
1183               responseCode = HttpServletResponse.SC_OK),
1184           @RestResponse(description = "The specified series does not exist.",
1185               responseCode = HttpServletResponse.SC_NOT_FOUND)
1186       })
1187   public Response updateSeriesAcl(@HeaderParam("Accept") String acceptHeader, @PathParam("seriesId") String seriesID,
1188           @FormParam("acl") String aclJson, @DefaultValue("false") @FormParam("override") boolean override)
1189           throws NotFoundException, SeriesException, UnauthorizedException {
1190     if (isBlank(aclJson)) {
1191       return R.badRequest("Missing form parameter 'acl'");
1192     }
1193 
1194     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
1195     if (requestedVersion.isSmallerThan(VERSION_1_2_0)) {
1196       // override was added in version 1.2.0 and should be ignored for smaller versions
1197       override = false;
1198     }
1199 
1200     JSONParser parser = new JSONParser();
1201     JSONArray acl;
1202     try {
1203       acl = (JSONArray) parser.parse(aclJson);
1204     } catch (ParseException e) {
1205       logger.debug("Could not parse ACL ({})", aclJson, e);
1206       return R.badRequest("Could not parse ACL");
1207     }
1208 
1209     List<AccessControlEntry> accessControlEntries = ((List<?>) acl).stream()
1210         .map(a -> {
1211           JSONObject ace = (JSONObject) a;
1212           return new AccessControlEntry(
1213               (String) ace.get("role"),
1214               (String) ace.get("action"),
1215               (Boolean) ace.get("allow")
1216           );
1217         })
1218         .collect(Collectors.toList());
1219 
1220     seriesService.updateAccessControl(seriesID, new AccessControlList(accessControlEntries), override);
1221     return ApiResponseBuilder.Json.ok(acceptHeader, aclJson);
1222   }
1223 
1224   @SuppressWarnings("unchecked")
1225   @PUT
1226   @Path("{seriesId}/properties")
1227   @RestQuery(
1228       name = "updateseriesproperties",
1229       description = "Updates a series' properties",
1230       returnDescription = "",
1231       pathParameters = {
1232           @RestParameter(name = "seriesId", description = "The series id", isRequired = true, type = STRING)
1233       },
1234       restParameters = {
1235           @RestParameter(name = "properties", isRequired = true, description = "Series properties", type = STRING)
1236       },
1237       responses = {
1238           @RestResponse(description = "Successfully updated the series' properties.",
1239               responseCode = HttpServletResponse.SC_OK),
1240           @RestResponse(description = "The specified series does not exist.",
1241               responseCode = HttpServletResponse.SC_NOT_FOUND)
1242       })
1243   public Response updateSeriesProperties(@HeaderParam("Accept") String acceptHeader,
1244           @PathParam("seriesId") String seriesID, @FormParam("properties") String propertiesJson)
1245           throws NotFoundException, SeriesException, UnauthorizedException {
1246     if (StringUtils.isBlank(propertiesJson)) {
1247       return R.badRequest("Missing form parameter 'acl'");
1248     }
1249 
1250     JSONParser parser = new JSONParser();
1251     JSONObject props;
1252     try {
1253       props = (JSONObject) parser.parse(propertiesJson);
1254     } catch (ParseException e) {
1255       logger.debug("Could not parse properties ({})", propertiesJson, e);
1256       return R.badRequest("Could not parse series properties");
1257     }
1258 
1259     for (Object prop : props.entrySet()) {
1260       Entry<String, Object> field = (Entry<String, Object>) prop;
1261       seriesService.updateSeriesProperty(seriesID, field.getKey(), field.getValue().toString());
1262     }
1263 
1264     return ApiResponseBuilder.Json.ok(acceptHeader, propertiesJson);
1265   }
1266 
1267   @GET
1268   @Produces(MediaType.APPLICATION_JSON)
1269   @Path("series.json")
1270   @RestQuery(
1271       name = "listSeriesAsJson",
1272       description = "Returns the series matching the query parameters",
1273       returnDescription = "Returns the series search results as JSON",
1274       restParameters = {
1275           @RestParameter(
1276               name = "q",
1277               isRequired = false,
1278               description = "Free text search",
1279               type = STRING
1280           ),
1281           @RestParameter(
1282               name = "edit",
1283               isRequired = false,
1284               description = "Whether this query should return only series that are editable",
1285               type = BOOLEAN
1286           ),
1287           @RestParameter(
1288               name = "fuzzyMatch",
1289               isRequired = false,
1290               description = "Whether a partial match on series id is allowed, default is false",
1291               type = BOOLEAN
1292           ),
1293           @RestParameter(
1294               name = "seriesId",
1295               isRequired = false,
1296               description = "The series identifier",
1297               type = STRING
1298           ),
1299           @RestParameter(
1300               name = "seriesTitle",
1301               isRequired = false,
1302               description = "The series title",
1303               type = STRING
1304           ),
1305           @RestParameter(
1306               name = "creator",
1307               isRequired = false,
1308               description = "The series creator",
1309               type = STRING
1310           ),
1311           @RestParameter(
1312               name = "contributor",
1313               isRequired = false,
1314               description = "The series contributor",
1315               type = STRING
1316           ),
1317           @RestParameter(
1318               name = "publisher",
1319               isRequired = false,
1320               description = "The series publisher",
1321               type = STRING
1322           ),
1323           @RestParameter(
1324               name = "rightsholder",
1325               isRequired = false,
1326               description = "The series rights holder",
1327               type = STRING
1328           ),
1329           @RestParameter(
1330               name = "createdfrom",
1331               isRequired = false,
1332               description = "Filter results by created from (yyyy-MM-dd'T'HH:mm:ss'Z')",
1333               type = STRING
1334           ),
1335           @RestParameter(
1336               name = "createdto",
1337               isRequired = false,
1338               description = "Filter results by created to (yyyy-MM-dd'T'HH:mm:ss'Z')",
1339               type = STRING
1340           ),
1341           @RestParameter(
1342               name = "language",
1343               isRequired = false,
1344               description = "The series language",
1345               type = STRING
1346           ),
1347           @RestParameter(
1348               name = "license",
1349               isRequired = false,
1350               description = "The series license",
1351               type = STRING
1352           ),
1353           @RestParameter(
1354               name = "subject",
1355               isRequired = false,
1356               description = "The series subject",
1357               type = STRING
1358           ),
1359           @RestParameter(
1360               name = "description",
1361               isRequired = false,
1362               description = "The series description",
1363               type = STRING
1364           ),
1365           @RestParameter(
1366               name = "sort",
1367               isRequired = false,
1368               description = "The sort order. May include any of the following: TITLE, SUBJECT, "
1369                       + "CREATOR, PUBLISHERS, CONTRIBUTORS, DESCRIPTION, CREATED_DATE_TIME, "
1370                       + "LANGUAGE, RIGHTS_HOLDER, MANAGED_ACL, LICENCE. "
1371                       + "Add '_DESC' to reverse the sort order (e.g. TITLE_DESC).",
1372               type = STRING
1373           ),
1374           @RestParameter(
1375               name = "offset",
1376               isRequired = false,
1377               description = "The offset",
1378               type = STRING
1379           ),
1380           @RestParameter(
1381               name = "count",
1382               isRequired = false,
1383               description = "Results per page (max 100)",
1384               type = STRING
1385           )
1386       },
1387       responses = {
1388           @RestResponse(
1389               responseCode = SC_OK,
1390               description = "The access control list."
1391           ),
1392           @RestResponse(
1393               responseCode = SC_UNAUTHORIZED,
1394               description = "If the current user is not authorized to perform this action"
1395           )
1396       }
1397   )
1398   public Response getSeriesAsJson(
1399           @QueryParam("q") String text,
1400           @QueryParam("seriesId") String seriesId,
1401           @QueryParam("edit") Boolean edit,
1402           @QueryParam("fuzzyMatch") Boolean fuzzyMatch,
1403           @QueryParam("seriesTitle") String seriesTitle,
1404           @QueryParam("creator") String creator,
1405           @QueryParam("contributor") String contributor,
1406           @QueryParam("publisher") String publisher,
1407           @QueryParam("rightsholder") String rightsHolder,
1408           @QueryParam("createdfrom") String createdFrom,
1409           @QueryParam("createdto") String createdTo,
1410           @QueryParam("language") String language,
1411           @QueryParam("license") String license,
1412           @QueryParam("subject") String subject,
1413           @QueryParam("description") String description,
1414           @QueryParam("sort") String sort,
1415           @QueryParam("offset") String offset,
1416           @QueryParam("count") String count
1417   ) throws UnauthorizedException {
1418     try {
1419       SearchResult<Series> items = getSeries(
1420               text, seriesId, edit, seriesTitle, creator, contributor, publisher,
1421               rightsHolder, createdFrom, createdTo, language, license, subject, description, sort,
1422               offset, count, fuzzyMatch);
1423 
1424       return queryResultToJson(items, false, ApiVersion.VERSION_1_7_0);
1425 
1426     } catch (UnauthorizedException e) {
1427       throw e;
1428     } catch (Exception e) {
1429       logger.warn("Could not perform search query: {}", e.getMessage());
1430     }
1431     throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
1432   }
1433 
1434   private SearchResult<Series> getSeries(
1435           String text,
1436           String seriesId,
1437           Boolean edit,
1438           String seriesTitle,
1439           String creator,
1440           String contributor,
1441           String publisher,
1442           String rightsHolder,
1443           String createdFrom,
1444           String createdTo,
1445           String language,
1446           String license,
1447           String subject,
1448           String description,
1449           String sort,
1450           String offsetString,
1451           String countString,
1452           Boolean fuzzyMatch
1453   ) throws SeriesException, UnauthorizedException {
1454     int offset = 0;
1455     if (StringUtils.isNotEmpty(offsetString)) {
1456       try {
1457         offset = Integer.parseInt(offsetString);
1458       } catch (NumberFormatException e) {
1459         logger.warn("Bad start page parameter");
1460       }
1461       if (offset < 0) {
1462         offset = 0;
1463       }
1464     }
1465 
1466     int count = DEFAULT_LIMIT;
1467     if (StringUtils.isNotEmpty(countString)) {
1468       try {
1469         count = Integer.parseInt(countString);
1470       } catch (NumberFormatException e) {
1471         logger.warn("Bad count parameter");
1472       }
1473       if (count < 1) {
1474         count = DEFAULT_LIMIT;
1475       }
1476     }
1477 
1478     SeriesSearchQuery q = new SeriesSearchQuery(securityService.getOrganization().getId(), securityService.getUser());
1479     q.withLimit(count);
1480     q.withOffset(offset);
1481     if (edit != null) {
1482       q.withEdit(edit);
1483     }
1484     if (StringUtils.isNotEmpty(text)) {
1485       q.withText(fuzzyMatch.booleanValue(), text);
1486     }
1487     if (StringUtils.isNotEmpty(seriesId)) {
1488       q.withIdentifier(seriesId);
1489     }
1490     if (StringUtils.isNotEmpty(seriesTitle)) {
1491       q.withTitle(seriesTitle);
1492     }
1493     if (StringUtils.isNotEmpty(creator)) {
1494       q.withCreator(creator);
1495     }
1496     if (StringUtils.isNotEmpty(contributor)) {
1497       q.withContributor(contributor);
1498     }
1499     if (StringUtils.isNotEmpty(language)) {
1500       q.withLanguage(language);
1501     }
1502     if (StringUtils.isNotEmpty(license)) {
1503       q.withLicense(license);
1504     }
1505     if (StringUtils.isNotEmpty(subject)) {
1506       q.withSubject(subject);
1507     }
1508     if (StringUtils.isNotEmpty(publisher)) {
1509       q.withPublisher(publisher);
1510     }
1511     if (StringUtils.isNotEmpty(description)) {
1512       q.withDescription(description);
1513     }
1514     if (StringUtils.isNotEmpty(rightsHolder)) {
1515       q.withRightsHolder(rightsHolder);
1516     }
1517     try {
1518       if (StringUtils.isNotEmpty(createdFrom)) {
1519         SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH);
1520         Date date = formatter.parse(createdFrom);
1521         q.withCreatedFrom(date);
1522       }
1523       if (StringUtils.isNotEmpty(createdTo)) {
1524         SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH);
1525         Date date = formatter.parse(createdTo);
1526         q.withCreatedFrom(date);
1527       }
1528     } catch (java.text.ParseException e1) {
1529       logger.warn("Could not parse date parameter", e1);
1530     }
1531 
1532     if (StringUtils.isNotBlank(sort)) {
1533       String enumKey;
1534       SortCriterion.Order order;
1535       if (sort.endsWith("_DESC")) {
1536         enumKey = sort.substring(0, sort.length() - "_DESC".length()).toUpperCase();
1537         order = SortCriterion.Order.Descending;
1538       } else {
1539         enumKey = sort;
1540         order = SortCriterion.Order.Ascending;
1541       }
1542 
1543       try {
1544         switch (enumKey) {
1545           case SeriesIndexSchema.TITLE:
1546             q.sortByTitle(order);
1547             break;
1548           case SeriesIndexSchema.SUBJECT:
1549             q.sortBySubject(order);
1550             break;
1551           case SeriesIndexSchema.CREATOR:
1552             q.sortByCreator(order);
1553             break;
1554           case SeriesIndexSchema.PUBLISHERS:
1555             q.sortByPublishers(order);
1556             break;
1557           case SeriesIndexSchema.CONTRIBUTORS:
1558             q.sortByContributors(order);
1559             break;
1560           case SeriesIndexSchema.DESCRIPTION:
1561             q.sortByDescription(order);
1562             break;
1563           case SeriesIndexSchema.LANGUAGE:
1564             q.sortByLanguage(order);
1565             break;
1566           case SeriesIndexSchema.RIGHTS_HOLDER:
1567             q.sortByRightsHolder(order);
1568             break;
1569           case SeriesIndexSchema.LICENSE:
1570             q.sortByLicense(order);
1571             break;
1572           case SeriesIndexSchema.CREATED_DATE_TIME:
1573             q.sortByCreatedDateTime(order);
1574             break;
1575           case SeriesIndexSchema.MANAGED_ACL:
1576             q.sortByManagedAcl(order);
1577             break;
1578           default:
1579             logger.info("Unknown filter criteria {}", enumKey);
1580             throw new IllegalArgumentException("Unknown filter criteria " + enumKey);
1581         }
1582       } catch (IllegalArgumentException e) {
1583         logger.warn("No sort enum matches '{}'", enumKey);
1584       }
1585     }
1586 
1587     try {
1588       return elasticsearchIndex.getByQuery(q);
1589     } catch (SearchIndexException e) {
1590       logger.error("Failed to execute search query: {}", e.getMessage());
1591       throw new SeriesException(e);
1592     }
1593   }
1594 
1595   /**
1596    * Parse two strings in UTC format into Date objects to represent a range of dates.
1597    *
1598    * @param createdFrom
1599    *          The string that represents the start date of the range.
1600    * @param createdTo
1601    *          The string that represents the end date of the range.
1602    * @return A Tuple with the two Dates
1603    * @throws IllegalArgumentException
1604    *           Thrown if the input strings are not valid UTC strings
1605    */
1606   private Tuple<Date, Date> getFromAndToCreationRange(String createdFrom, String createdTo) {
1607     Date createdFromDate = null;
1608     Date createdToDate = null;
1609     if ((StringUtils.isNotBlank(createdFrom) && StringUtils.isBlank(createdTo))
1610             || (StringUtils.isBlank(createdFrom) && StringUtils.isNotBlank(createdTo))) {
1611       logger.error("Both createdTo '{}' and createdFrom '{}' have to be specified or neither of them", createdTo,
1612               createdFrom);
1613       throw new IllegalArgumentException("Both createdTo '" + createdTo + "' and createdFrom '" + createdFrom
1614               + "' have to be specified or neither of them");
1615     } else {
1616 
1617       if (StringUtils.isNotBlank(createdFrom)) {
1618         try {
1619           createdFromDate = new Date(DateTimeSupport.fromUTC(createdFrom));
1620         } catch (IllegalStateException e) {
1621           logger.error("Unable to parse createdFrom parameter '{}'", createdFrom, e);
1622           throw new IllegalArgumentException("Unable to parse createdFrom parameter.");
1623         } catch (java.text.ParseException e) {
1624           logger.error("Unable to parse createdFrom parameter '{}'", createdFrom, e);
1625           throw new IllegalArgumentException("Unable to parse createdFrom parameter.");
1626         }
1627       }
1628 
1629       if (StringUtils.isNotBlank(createdTo)) {
1630         try {
1631           createdToDate = new Date(DateTimeSupport.fromUTC(createdTo));
1632         } catch (IllegalStateException e) {
1633           logger.error("Unable to parse createdTo parameter '{}'", createdTo, e);
1634           throw new IllegalArgumentException("Unable to parse createdTo parameter.");
1635         } catch (java.text.ParseException e) {
1636           logger.error("Unable to parse createdTo parameter '{}'", createdTo, e);
1637           throw new IllegalArgumentException("Unable to parse createdTo parameter.");
1638         }
1639       }
1640     }
1641     return new Tuple<>(createdFromDate, createdToDate);
1642   }
1643 
1644   private String getSeriesUrl(String seriesId) {
1645     return UrlSupport.concat(endpointBaseUrl, seriesId);
1646   }
1647 }