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