View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  package org.opencastproject.external.endpoint;
22  
23  import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
24  import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
25  import static javax.servlet.http.HttpServletResponse.SC_CREATED;
26  import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
27  import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
28  import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
29  import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
30  import static javax.servlet.http.HttpServletResponse.SC_OK;
31  import static org.apache.commons.lang3.exception.ExceptionUtils.getMessage;
32  import static org.opencastproject.external.common.ApiVersion.VERSION_1_6_0;
33  import static org.opencastproject.index.service.util.JSONUtils.safeString;
34  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
35  
36  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
37  import org.opencastproject.external.common.ApiMediaType;
38  import org.opencastproject.external.common.ApiResponseBuilder;
39  import org.opencastproject.external.common.ApiVersion;
40  import org.opencastproject.index.service.resources.list.query.GroupsListQuery;
41  import org.opencastproject.index.service.util.RestUtils;
42  import org.opencastproject.security.api.SecurityService;
43  import org.opencastproject.security.api.UnauthorizedException;
44  import org.opencastproject.security.impl.jpa.JpaGroup;
45  import org.opencastproject.userdirectory.ConflictException;
46  import org.opencastproject.userdirectory.JpaGroupRoleProvider;
47  import org.opencastproject.util.NotFoundException;
48  import org.opencastproject.util.doc.rest.RestParameter;
49  import org.opencastproject.util.doc.rest.RestQuery;
50  import org.opencastproject.util.doc.rest.RestResponse;
51  import org.opencastproject.util.doc.rest.RestService;
52  import org.opencastproject.util.requests.SortCriterion;
53  
54  import com.google.gson.JsonArray;
55  import com.google.gson.JsonObject;
56  
57  import org.apache.commons.collections4.ComparatorUtils;
58  import org.apache.commons.lang3.StringUtils;
59  import org.osgi.service.component.annotations.Activate;
60  import org.osgi.service.component.annotations.Component;
61  import org.osgi.service.component.annotations.Reference;
62  import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
63  import org.slf4j.Logger;
64  import org.slf4j.LoggerFactory;
65  
66  import java.util.ArrayList;
67  import java.util.Collections;
68  import java.util.Comparator;
69  import java.util.HashMap;
70  import java.util.HashSet;
71  import java.util.List;
72  import java.util.Map;
73  import java.util.Optional;
74  import java.util.Set;
75  import java.util.stream.Collectors;
76  
77  import javax.servlet.http.HttpServletResponse;
78  import javax.ws.rs.DELETE;
79  import javax.ws.rs.FormParam;
80  import javax.ws.rs.GET;
81  import javax.ws.rs.HeaderParam;
82  import javax.ws.rs.POST;
83  import javax.ws.rs.PUT;
84  import javax.ws.rs.Path;
85  import javax.ws.rs.PathParam;
86  import javax.ws.rs.Produces;
87  import javax.ws.rs.QueryParam;
88  import javax.ws.rs.WebApplicationException;
89  import javax.ws.rs.core.Response;
90  
91  @Path("/api/groups")
92  @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_0_0, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0,
93              ApiMediaType.VERSION_1_3_0, ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0,
94              ApiMediaType.VERSION_1_6_0, ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0,
95              ApiMediaType.VERSION_1_9_0, ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
96  @RestService(
97      name = "externalapigroups",
98      title = "External API Groups Service",
99      notes = {},
100     abstractText = "Provides resources and operations related to the groups"
101 )
102 @Component(
103     immediate = true,
104     service = GroupsEndpoint.class,
105     property = {
106         "service.description=External API - Groups Endpoint",
107         "opencast.service.type=org.opencastproject.external.groups",
108         "opencast.service.path=/api/groups"
109     }
110 )
111 @JaxrsResource
112 public class GroupsEndpoint {
113 
114   /** The logging facility */
115   private static final Logger logger = LoggerFactory.getLogger(GroupsEndpoint.class);
116 
117   /* OSGi service references */
118   private ElasticsearchIndex elasticsearchIndex;
119   private JpaGroupRoleProvider jpaGroupRoleProvider;
120   private SecurityService securityService;
121 
122   /** OSGi DI */
123   @Reference
124   void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
125     this.elasticsearchIndex = elasticsearchIndex;
126   }
127 
128   /** OSGi DI. */
129   @Reference
130   public void setSecurityService(SecurityService securityService) {
131     this.securityService = securityService;
132   }
133 
134   /** OSGi DI */
135   @Reference
136   public void setGroupRoleProvider(JpaGroupRoleProvider jpaGroupRoleProvider) {
137     this.jpaGroupRoleProvider = jpaGroupRoleProvider;
138   }
139 
140   /** OSGi activation method */
141   @Activate
142   void activate() {
143     logger.info("Activating External API - Groups Endpoint");
144   }
145 
146   @GET
147   @Path("")
148   @RestQuery(
149       name = "getgroups",
150       description = "Returns a list of groups.",
151       returnDescription = "",
152       restParameters = {
153           @RestParameter(name = "filter", isRequired = false, description = "A comma seperated list of filters to "
154               + "limit the results with. A filter is the filter's name followed by a colon \":\" and then the value to "
155               + "filter with so it is the form [Filter Name]:[Value to Filter With].", type = STRING),
156           @RestParameter(name = "sort", description = "Sort the results based upon a list of comma seperated sorting "
157               + "criteria. In the comma seperated list each type of sorting is specified as a pair such as: "
158               + "<Sort Name>:ASC or <Sort Name>:DESC. Adding the suffix ASC or DESC sets the order as ascending or "
159               + "descending order and is mandatory.", isRequired = false, type = STRING),
160           @RestParameter(name = "limit", description = "The maximum number of results to return for a single request.",
161               isRequired = false, type = RestParameter.Type.INTEGER),
162           @RestParameter(name = "offset", description = "The index of the first result to return.", isRequired = false,
163               type = RestParameter.Type.INTEGER)
164       },
165       responses = {
166           @RestResponse(description = "A (potentially empty) list of groups.", responseCode = HttpServletResponse.SC_OK)
167       })
168   public Response getGroups(@HeaderParam("Accept") String acceptHeader, @QueryParam("filter") String filter,
169           @QueryParam("sort") String sort, @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit) {
170     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
171     Optional<Integer> optLimit = Optional.ofNullable(limit);
172     Optional<Integer> optOffset = Optional.ofNullable(offset);
173 
174     if (optLimit.isPresent() && limit <= 0) {
175       optLimit = Optional.empty();
176     }
177     if (optOffset.isPresent() && offset < 0) {
178       optOffset = Optional.empty();
179     }
180 
181     // The API currently does not offer full text search for groups
182     Map<String, String> filters = new HashMap<>();
183     if (StringUtils.isNotBlank(filter)) {
184       for (String f : filter.split(",")) {
185         String[] filterTuple = f.split(":");
186         if (filterTuple.length < 2) {
187           logger.debug("No value for filter '{}' in filters list: {}", filterTuple[0], filter);
188           continue;
189         }
190         // use substring because dates also contain : so there might be more than two parts
191         filters.put(filterTuple[0].trim(), f.substring(filterTuple[0].length() + 1).trim());
192       }
193     }
194     Optional<String> optNameFilter = Optional.ofNullable(filters.get(GroupsListQuery.FILTER_NAME_NAME));
195 
196     ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(sort);
197 
198     // sorting by members & roles is not supported by the database, so we do this afterwards
199     Set<SortCriterion> deprecatedSortCriteria = new HashSet<>();
200     if (requestedVersion.isSmallerThan(VERSION_1_6_0)) {
201       deprecatedSortCriteria = sortCriteria.stream().filter(
202               sortCriterion -> sortCriterion.getFieldName().equals("members")
203                       || sortCriterion.getFieldName().equals("roles")).collect(Collectors.toSet());
204       sortCriteria.removeAll(deprecatedSortCriteria);
205     }
206 
207     List<JpaGroup> results = jpaGroupRoleProvider.getGroups(optLimit, optOffset, optNameFilter,
208         Optional.empty(), Optional.empty(), sortCriteria);
209 
210     // sorting by members & roles is only available for api versions < 1.6.0
211     if (requestedVersion.isSmallerThan(VERSION_1_6_0)) {
212       List<Comparator<JpaGroup>> comparators = new ArrayList<>();
213       for (SortCriterion sortCriterion : deprecatedSortCriteria) {
214         Comparator<JpaGroup> comparator;
215         switch (sortCriterion.getFieldName()) {
216           case "members":
217             comparator = new GroupComparator() {
218               @Override
219               public Set<String> getGroupAttribute(JpaGroup jpaGroup) {
220                 return jpaGroup.getMembers();
221               }
222             };
223             break;
224           case "roles":
225             comparator = new GroupComparator() {
226               @Override
227               public Set<String> getGroupAttribute(JpaGroup jpaGroup) {
228                 return jpaGroup.getRoleNames();
229               }
230             };
231             break;
232           default:
233             continue;
234         }
235 
236         if (sortCriterion.getOrder() == SortCriterion.Order.Descending) {
237           comparator = comparator.reversed();
238         }
239         comparators.add(comparator);
240       }
241       Collections.sort(results, ComparatorUtils.chainedComparator(comparators));
242     }
243 
244     List<JsonObject> groupsJson = new ArrayList<>();
245     for (JpaGroup group : results) {
246       JsonObject groupJson = new JsonObject();
247 
248       groupJson.addProperty("identifier", group.getGroupId());
249       groupJson.addProperty("organization", group.getOrganization().getId());
250       groupJson.addProperty("role", group.getRole());
251       groupJson.addProperty("name", safeString(group.getName()));
252       groupJson.addProperty("description", safeString(group.getDescription()));
253       groupJson.addProperty("roles", group.getRoleNames() != null ? String.join(",", group.getRoleNames()) : "");
254       groupJson.addProperty("members", group.getMembers() != null ? String.join(",", group.getMembers()) : "");
255 
256       groupsJson.add(groupJson);
257     }
258     JsonArray responseArray = new JsonArray();
259     groupsJson.forEach(responseArray::add);
260 
261     return ApiResponseBuilder.Json.ok(acceptHeader, responseArray);
262   }
263 
264   /**
265    * Compare groups by set attributes like members or roles.
266    */
267   private abstract class GroupComparator implements Comparator<JpaGroup> {
268 
269     public abstract Set<String> getGroupAttribute(JpaGroup jpaGroup);
270 
271     @Override
272     public int compare(JpaGroup group1, JpaGroup group2) {
273       List<String> members1 = new ArrayList<>(getGroupAttribute(group1));
274       List<String> members2 = new ArrayList<>(getGroupAttribute(group2));
275 
276       for (int i = 0; i < members1.size() && i < members2.size(); i++) {
277         String member1 = members1.get(i);
278         String member2 = members2.get(i);
279         int result = member1.compareTo(member2);
280         if (result != 0) {
281           return result;
282         }
283       }
284       return (members1.size() - members2.size());
285     }
286   }
287 
288   @GET
289   @Path("{groupId}")
290   @RestQuery(
291       name = "getgroup",
292       description = "Returns a single group.",
293       returnDescription = "",
294       pathParameters = {
295           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING)
296       },
297       responses = {
298           @RestResponse(description = "The group is returned.", responseCode = HttpServletResponse.SC_OK),
299           @RestResponse(description = "The specified group does not exist.",
300               responseCode = HttpServletResponse.SC_NOT_FOUND)
301       })
302   public Response getGroup(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id) {
303     JpaGroup group = jpaGroupRoleProvider.getGroup(id);
304 
305     if (group == null) {
306       return ApiResponseBuilder.notFound("Cannot find a group with id '%s'.", id);
307     }
308 
309     JsonObject json = new JsonObject();
310     json.addProperty("identifier", group.getGroupId());
311     json.addProperty("organization", group.getOrganization().getId());
312     json.addProperty("role", group.getRole());
313     json.addProperty("name", safeString(group.getName()));
314     json.addProperty("description", safeString(group.getDescription()));
315     json.addProperty("roles", safeString(group.getRoleNames()));
316     json.addProperty("members", safeString(group.getMembers()));
317 
318     return ApiResponseBuilder.Json.ok(acceptHeader, json);
319   }
320 
321   @DELETE
322   @Path("{groupId}")
323   @RestQuery(
324       name = "deletegroup",
325       description = "Deletes a group.",
326       returnDescription = "",
327       pathParameters = {
328           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING)
329       },
330       responses = {
331           @RestResponse(description = "The group has been deleted.", responseCode = HttpServletResponse.SC_NO_CONTENT),
332           @RestResponse(description = "The specified group does not exist.",
333               responseCode = HttpServletResponse.SC_NOT_FOUND)
334       })
335   public Response deleteGroup(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id)
336           throws NotFoundException {
337     try {
338       jpaGroupRoleProvider.removeGroup(id);
339       return Response.noContent().build();
340     } catch (NotFoundException e) {
341       return Response.status(SC_NOT_FOUND).build();
342     } catch (UnauthorizedException e) {
343       return Response.status(SC_FORBIDDEN).build();
344     } catch (Exception e) {
345       logger.error("Unable to delete group {}", id, e);
346       throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
347     }
348   }
349 
350   @PUT
351   @Path("{groupId}")
352   @RestQuery(
353       name = "updategroup",
354       description = "Updates a group.",
355       returnDescription = "",
356       pathParameters = {
357           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING)
358       },
359       restParameters = {
360           @RestParameter(name = "name", isRequired = false, description = "Group Name", type = STRING),
361           @RestParameter(name = "description", description = "Group Description", isRequired = false, type = STRING),
362           @RestParameter(name = "roles", description = "Comma-separated list of roles", isRequired = false,
363               type = STRING),
364           @RestParameter(name = "members", description = "Comma-separated list of members", isRequired = false,
365               type = STRING)
366       },
367       responses = {
368           @RestResponse(description = "The group has been updated.", responseCode = HttpServletResponse.SC_CREATED),
369           @RestResponse(description = "The specified group does not exist.",
370               responseCode = HttpServletResponse.SC_BAD_REQUEST)
371       })
372   public Response updateGroup(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id,
373           @FormParam("name") String name, @FormParam("description") String description,
374           @FormParam("roles") String roles, @FormParam("members") String members) throws Exception {
375     try {
376       jpaGroupRoleProvider.updateGroup(id, name, description, roles, members);
377     } catch (IllegalArgumentException e) {
378       logger.warn("Unable to update group id {}: {}", id, e.getMessage());
379       return Response.status(SC_BAD_REQUEST).build();
380     } catch (UnauthorizedException ex) {
381       return Response.status(SC_FORBIDDEN).build();
382     }
383     return Response.ok().build();
384   }
385 
386   @POST
387   @Path("")
388   @RestQuery(
389       name = "creategroup",
390       description = "Creates a group.",
391       returnDescription = "",
392       restParameters = {
393           @RestParameter(name = "name", isRequired = true, description = "Group Name", type = STRING),
394           @RestParameter(name = "description", description = "Group Description", isRequired = false, type = STRING),
395           @RestParameter(name = "roles", description = "Comma-separated list of roles", isRequired = false,
396               type = STRING),
397           @RestParameter(name = "members", description = "Comma-separated list of members", isRequired = false,
398               type = STRING)
399       },
400       responses = {
401           @RestResponse(description = "A new group is created.", responseCode = SC_CREATED),
402           @RestResponse(description = "The request is invalid or inconsistent.", responseCode = SC_BAD_REQUEST)
403       })
404   public Response createGroup(@HeaderParam("Accept") String acceptHeader, @FormParam("name") String name,
405           @FormParam("description") String description, @FormParam("roles") String roles,
406           @FormParam("members") String members) {
407     try {
408       jpaGroupRoleProvider.createGroup(name, description, roles, members);
409     } catch (IllegalArgumentException e) {
410       logger.warn("Unable to create group {}: {}", name, e.getMessage());
411       return Response.status(SC_BAD_REQUEST).build();
412     } catch (UnauthorizedException e) {
413       return Response.status(SC_FORBIDDEN).build();
414     } catch (ConflictException e) {
415       return Response.status(SC_CONFLICT).build();
416     }
417     return Response.status(SC_CREATED).build();
418   }
419 
420   @POST
421   @Path("{groupId}/members")
422   @RestQuery(
423       name = "addgroupmember",
424       description = "Adds a member to a group.",
425       returnDescription = "",
426       pathParameters = {
427           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING)
428       },
429       restParameters = {
430           @RestParameter(name = "member", description = "Member Name", isRequired = true, type = STRING)
431       },
432       responses = {
433           @RestResponse(description = "The member was already member of the group.", responseCode = SC_OK),
434           @RestResponse(description = "The member has been added.", responseCode = SC_NO_CONTENT),
435           @RestResponse(description = "The specified group does not exist.", responseCode = SC_NOT_FOUND)
436       })
437   public Response addGroupMember(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id,
438           @FormParam("member") String member) {
439     try {
440       if (jpaGroupRoleProvider.addMemberToGroup(id, member)) {
441         return Response.ok().build();
442       } else {
443         return ApiResponseBuilder.Json.ok(acceptHeader, "Member is already member of group.");
444       }
445     } catch (IllegalArgumentException e) {
446       logger.warn("Unable to add member to group id {}.", id, e);
447       return Response.status(SC_BAD_REQUEST).build();
448     } catch (UnauthorizedException ex) {
449       return Response.status(SC_FORBIDDEN).build();
450     } catch (NotFoundException e) {
451       return ApiResponseBuilder.notFound("Cannot find group with id '%s'.", id);
452     } catch (Exception e) {
453       logger.warn("Could not update the group with id {}.",id, e);
454       return ApiResponseBuilder.serverError("Could not update group with id '%s', reason: '%s'",id,getMessage(e));
455     }
456   }
457 
458   @DELETE
459   @Path("{groupId}/members/{memberId}")
460   @RestQuery(
461       name = "removegroupmember",
462       description = "Removes a member from a group",
463       returnDescription = "",
464       pathParameters = {
465           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING),
466           @RestParameter(name = "memberId", description = "The member id", isRequired = true, type = STRING)
467       },
468       responses = {
469           @RestResponse(description = "The member has been removed.", responseCode = HttpServletResponse.SC_NO_CONTENT),
470           @RestResponse(description = "The specified group or member does not exist.",
471               responseCode = HttpServletResponse.SC_NOT_FOUND)
472       })
473   public Response removeGroupMember(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id,
474           @PathParam("memberId") String memberId) {
475     try {
476       if (jpaGroupRoleProvider.removeMemberFromGroup(id, memberId)) {
477         return Response.ok().build();
478       } else {
479         return ApiResponseBuilder.Json.ok(acceptHeader, "Member is already not member of group.");
480       }
481     } catch (IllegalArgumentException e) {
482       logger.warn("Unable to remove member from group id {}.", id, e);
483       return Response.status(SC_BAD_REQUEST).build();
484     } catch (UnauthorizedException ex) {
485       return Response.status(SC_FORBIDDEN).build();
486     } catch (NotFoundException e) {
487       return ApiResponseBuilder.notFound("Cannot find group with id '%s'.", id);
488     } catch (Exception e) {
489       logger.warn("Could not update the group with id {}.", id, e);
490       return ApiResponseBuilder.serverError("Could not update group with id '%s', reason: '%s'", id, getMessage(e));
491     }
492   }
493 }