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