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