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(name = "externalapigroups", title = "External API Groups Service", notes = {}, abstractText = "Provides resources and operations related to the groups")
97  @Component(
98      immediate = true,
99      service = GroupsEndpoint.class,
100     property = {
101         "service.description=External API - Groups Endpoint",
102         "opencast.service.type=org.opencastproject.external.groups",
103         "opencast.service.path=/api/groups"
104     }
105 )
106 @JaxrsResource
107 public class GroupsEndpoint {
108 
109   /** The logging facility */
110   private static final Logger logger = LoggerFactory.getLogger(GroupsEndpoint.class);
111 
112   /* OSGi service references */
113   private ElasticsearchIndex elasticsearchIndex;
114   private JpaGroupRoleProvider jpaGroupRoleProvider;
115   private SecurityService securityService;
116 
117   /** OSGi DI */
118   @Reference
119   void setElasticsearchIndex(ElasticsearchIndex elasticsearchIndex) {
120     this.elasticsearchIndex = elasticsearchIndex;
121   }
122 
123   /** OSGi DI. */
124   @Reference
125   public void setSecurityService(SecurityService securityService) {
126     this.securityService = securityService;
127   }
128 
129   /** OSGi DI */
130   @Reference
131   public void setGroupRoleProvider(JpaGroupRoleProvider jpaGroupRoleProvider) {
132     this.jpaGroupRoleProvider = jpaGroupRoleProvider;
133   }
134 
135   /** OSGi activation method */
136   @Activate
137   void activate() {
138     logger.info("Activating External API - Groups Endpoint");
139   }
140 
141   @GET
142   @Path("")
143   @RestQuery(name = "getgroups", description = "Returns a list of groups.", returnDescription = "", restParameters = {
144           @RestParameter(name = "filter", isRequired = false, description = "A comma seperated list of filters to limit the results with. A filter is the filter's name followed by a colon \":\" and then the value to filter with so it is the form [Filter Name]:[Value to Filter With].", type = STRING),
145           @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),
146           @RestParameter(name = "limit", description = "The maximum number of results to return for a single request.", isRequired = false, type = RestParameter.Type.INTEGER),
147           @RestParameter(name = "offset", description = "The index of the first result to return.", isRequired = false, type = RestParameter.Type.INTEGER) }, responses = {
148                   @RestResponse(description = "A (potentially empty) list of groups.", responseCode = HttpServletResponse.SC_OK) })
149   public Response getGroups(@HeaderParam("Accept") String acceptHeader, @QueryParam("filter") String filter,
150           @QueryParam("sort") String sort, @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit) {
151     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
152     Optional<Integer> optLimit = Optional.ofNullable(limit);
153     Optional<Integer> optOffset = Optional.ofNullable(offset);
154 
155     if (optLimit.isPresent() && limit <= 0) {
156       optLimit = Optional.empty();
157     }
158     if (optOffset.isPresent() && offset < 0) {
159       optOffset = Optional.empty();
160     }
161 
162     // The API currently does not offer full text search for groups
163     Map<String, String> filters = new HashMap<>();
164     if (StringUtils.isNotBlank(filter)) {
165       for (String f : filter.split(",")) {
166         String[] filterTuple = f.split(":");
167         if (filterTuple.length < 2) {
168           logger.debug("No value for filter '{}' in filters list: {}", filterTuple[0], filter);
169           continue;
170         }
171         // use substring because dates also contain : so there might be more than two parts
172         filters.put(filterTuple[0].trim(), f.substring(filterTuple[0].length() + 1).trim());
173       }
174     }
175     Optional<String> optNameFilter = Optional.ofNullable(filters.get(GroupsListQuery.FILTER_NAME_NAME));
176 
177     ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(sort);
178 
179     // sorting by members & roles is not supported by the database, so we do this afterwards
180     Set<SortCriterion> deprecatedSortCriteria = new HashSet<>();
181     if (requestedVersion.isSmallerThan(VERSION_1_6_0)) {
182       deprecatedSortCriteria = sortCriteria.stream().filter(
183               sortCriterion -> sortCriterion.getFieldName().equals("members")
184                       || sortCriterion.getFieldName().equals("roles")).collect(Collectors.toSet());
185       sortCriteria.removeAll(deprecatedSortCriteria);
186     }
187 
188     List<JpaGroup> results = jpaGroupRoleProvider.getGroups(optLimit, optOffset, optNameFilter,
189         Optional.empty(), Optional.empty(), sortCriteria);
190 
191     // sorting by members & roles is only available for api versions < 1.6.0
192     if (requestedVersion.isSmallerThan(VERSION_1_6_0)) {
193       List<Comparator<JpaGroup>> comparators = new ArrayList<>();
194       for (SortCriterion sortCriterion : deprecatedSortCriteria) {
195         Comparator<JpaGroup> comparator;
196         switch (sortCriterion.getFieldName()) {
197           case "members":
198             comparator = new GroupComparator() {
199               @Override
200               public Set<String> getGroupAttribute(JpaGroup jpaGroup) {
201                 return jpaGroup.getMembers();
202               }
203             };
204             break;
205           case "roles":
206             comparator = new GroupComparator() {
207               @Override
208               public Set<String> getGroupAttribute(JpaGroup jpaGroup) {
209                 return jpaGroup.getRoleNames();
210               }
211             };
212             break;
213           default:
214             continue;
215         }
216 
217         if (sortCriterion.getOrder() == SortCriterion.Order.Descending) {
218           comparator = comparator.reversed();
219         }
220         comparators.add(comparator);
221       }
222       Collections.sort(results, ComparatorUtils.chainedComparator(comparators));
223     }
224 
225     List<JsonObject> groupsJson = new ArrayList<>();
226     for (JpaGroup group : results) {
227       JsonObject groupJson = new JsonObject();
228 
229       groupJson.addProperty("identifier", group.getGroupId());
230       groupJson.addProperty("organization", group.getOrganization().getId());
231       groupJson.addProperty("role", group.getRole());
232       groupJson.addProperty("name", safeString(group.getName()));
233       groupJson.addProperty("description", safeString(group.getDescription()));
234       groupJson.addProperty("roles", group.getRoleNames() != null ? String.join(",", group.getRoleNames()) : "");
235       groupJson.addProperty("members", group.getMembers() != null ? String.join(",", group.getMembers()) : "");
236 
237       groupsJson.add(groupJson);
238     }
239     JsonArray responseArray = new JsonArray();
240     groupsJson.forEach(responseArray::add);
241 
242     return ApiResponseBuilder.Json.ok(acceptHeader, responseArray);
243   }
244 
245   /**
246    * Compare groups by set attributes like members or roles.
247    */
248   private abstract class GroupComparator implements Comparator<JpaGroup> {
249 
250     public abstract Set<String> getGroupAttribute(JpaGroup jpaGroup);
251 
252     @Override
253     public int compare(JpaGroup group1, JpaGroup group2) {
254       List<String> members1 = new ArrayList<>(getGroupAttribute(group1));
255       List<String> members2 = new ArrayList<>(getGroupAttribute(group2));
256 
257       for (int i = 0; i < members1.size() && i < members2.size(); i++) {
258         String member1 = members1.get(i);
259         String member2 = members2.get(i);
260         int result = member1.compareTo(member2);
261         if (result != 0) {
262           return result;
263         }
264       }
265       return (members1.size() - members2.size());
266     }
267   }
268 
269   @GET
270   @Path("{groupId}")
271   @RestQuery(name = "getgroup", description = "Returns a single group.", returnDescription = "", pathParameters = {
272           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING) }, responses = {
273                   @RestResponse(description = "The group is returned.", responseCode = HttpServletResponse.SC_OK),
274                   @RestResponse(description = "The specified group does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
275   public Response getGroup(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id) {
276     JpaGroup group = jpaGroupRoleProvider.getGroup(id);
277 
278     if (group == null) {
279       return ApiResponseBuilder.notFound("Cannot find a group with id '%s'.", id);
280     }
281 
282     JsonObject json = new JsonObject();
283     json.addProperty("identifier", group.getGroupId());
284     json.addProperty("organization", group.getOrganization().getId());
285     json.addProperty("role", group.getRole());
286     json.addProperty("name", safeString(group.getName()));
287     json.addProperty("description", safeString(group.getDescription()));
288     json.addProperty("roles", safeString(group.getRoleNames()));
289     json.addProperty("members", safeString(group.getMembers()));
290 
291     return ApiResponseBuilder.Json.ok(acceptHeader, json);
292   }
293 
294   @DELETE
295   @Path("{groupId}")
296   @RestQuery(name = "deletegroup", description = "Deletes a group.", returnDescription = "", pathParameters = {
297           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING) }, responses = {
298                   @RestResponse(description = "The group has been deleted.", responseCode = HttpServletResponse.SC_NO_CONTENT),
299                   @RestResponse(description = "The specified group does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
300   public Response deleteGroup(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id)
301           throws NotFoundException {
302     try {
303       jpaGroupRoleProvider.removeGroup(id);
304       return Response.noContent().build();
305     } catch (NotFoundException e) {
306       return Response.status(SC_NOT_FOUND).build();
307     } catch (UnauthorizedException e) {
308       return Response.status(SC_FORBIDDEN).build();
309     } catch (Exception e) {
310       logger.error("Unable to delete group {}", id, e);
311       throw new WebApplicationException(e, SC_INTERNAL_SERVER_ERROR);
312     }
313   }
314 
315   @PUT
316   @Path("{groupId}")
317   @RestQuery(name = "updategroup", description = "Updates a group.", returnDescription = "", pathParameters = {
318           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING) }, restParameters = {
319                   @RestParameter(name = "name", isRequired = false, description = "Group Name", type = STRING),
320                   @RestParameter(name = "description", description = "Group Description", isRequired = false, type = STRING),
321                   @RestParameter(name = "roles", description = "Comma-separated list of roles", isRequired = false, type = STRING),
322                   @RestParameter(name = "members", description = "Comma-separated list of members", isRequired = false, type = STRING) }, responses = {
323                           @RestResponse(description = "The group has been updated.", responseCode = HttpServletResponse.SC_CREATED),
324                           @RestResponse(description = "The specified group does not exist.", responseCode = HttpServletResponse.SC_BAD_REQUEST) })
325   public Response updateGroup(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id,
326           @FormParam("name") String name, @FormParam("description") String description,
327           @FormParam("roles") String roles, @FormParam("members") String members) throws Exception {
328     try {
329       jpaGroupRoleProvider.updateGroup(id, name, description, roles, members);
330     } catch (IllegalArgumentException e) {
331       logger.warn("Unable to update group id {}: {}", id, e.getMessage());
332       return Response.status(SC_BAD_REQUEST).build();
333     } catch (UnauthorizedException ex) {
334       return Response.status(SC_FORBIDDEN).build();
335     }
336     return Response.ok().build();
337   }
338 
339   @POST
340   @Path("")
341   @RestQuery(name = "creategroup", description = "Creates a group.", returnDescription = "", restParameters = {
342           @RestParameter(name = "name", isRequired = true, description = "Group Name", type = STRING),
343           @RestParameter(name = "description", description = "Group Description", isRequired = false, type = STRING),
344           @RestParameter(name = "roles", description = "Comma-separated list of roles", isRequired = false, type = STRING),
345           @RestParameter(name = "members", description = "Comma-separated list of members", isRequired = false, type = STRING) }, responses = {
346                   @RestResponse(description = "A new group is created.", responseCode = SC_CREATED),
347                   @RestResponse(description = "The request is invalid or inconsistent.", responseCode = SC_BAD_REQUEST) })
348   public Response createGroup(@HeaderParam("Accept") String acceptHeader, @FormParam("name") String name,
349           @FormParam("description") String description, @FormParam("roles") String roles,
350           @FormParam("members") String members) {
351     try {
352       jpaGroupRoleProvider.createGroup(name, description, roles, members);
353     } catch (IllegalArgumentException e) {
354       logger.warn("Unable to create group {}: {}", name, e.getMessage());
355       return Response.status(SC_BAD_REQUEST).build();
356     } catch (UnauthorizedException e) {
357       return Response.status(SC_FORBIDDEN).build();
358     } catch (ConflictException e) {
359       return Response.status(SC_CONFLICT).build();
360     }
361     return Response.status(SC_CREATED).build();
362   }
363 
364   @POST
365   @Path("{groupId}/members")
366   @RestQuery(name = "addgroupmember", description = "Adds a member to a group.", returnDescription = "", pathParameters = {
367           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING) }, restParameters = {
368                   @RestParameter(name = "member", description = "Member Name", isRequired = true, type = STRING) }, responses = {
369                           @RestResponse(description = "The member was already member of the group.", responseCode = SC_OK),
370                           @RestResponse(description = "The member has been added.", responseCode = SC_NO_CONTENT),
371                           @RestResponse(description = "The specified group does not exist.", responseCode = SC_NOT_FOUND) })
372   public Response addGroupMember(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id,
373           @FormParam("member") String member) {
374     try {
375       if (jpaGroupRoleProvider.addMemberToGroup(id, member)) {
376         return Response.ok().build();
377       } else {
378         return ApiResponseBuilder.Json.ok(acceptHeader, "Member is already member of group.");
379       }
380     } catch (IllegalArgumentException e) {
381       logger.warn("Unable to add member to group id {}.", id, e);
382       return Response.status(SC_BAD_REQUEST).build();
383     } catch (UnauthorizedException ex) {
384       return Response.status(SC_FORBIDDEN).build();
385     } catch (NotFoundException e) {
386       return ApiResponseBuilder.notFound("Cannot find group with id '%s'.", id);
387     } catch (Exception e) {
388       logger.warn("Could not update the group with id {}.",id, e);
389       return ApiResponseBuilder.serverError("Could not update group with id '%s', reason: '%s'",id,getMessage(e));
390     }
391   }
392 
393   @DELETE
394   @Path("{groupId}/members/{memberId}")
395   @RestQuery(name = "removegroupmember", description = "Removes a member from a group", returnDescription = "", pathParameters = {
396           @RestParameter(name = "groupId", description = "The group id", isRequired = true, type = STRING),
397           @RestParameter(name = "memberId", description = "The member id", isRequired = true, type = STRING) }, responses = {
398                   @RestResponse(description = "The member has been removed.", responseCode = HttpServletResponse.SC_NO_CONTENT),
399                   @RestResponse(description = "The specified group or member does not exist.", responseCode = HttpServletResponse.SC_NOT_FOUND) })
400   public Response removeGroupMember(@HeaderParam("Accept") String acceptHeader, @PathParam("groupId") String id,
401           @PathParam("memberId") String memberId) {
402     try {
403       if (jpaGroupRoleProvider.removeMemberFromGroup(id, memberId)) {
404         return Response.ok().build();
405       } else {
406         return ApiResponseBuilder.Json.ok(acceptHeader, "Member is already not member of group.");
407       }
408     } catch (IllegalArgumentException e) {
409       logger.warn("Unable to remove member from group id {}.", id, e);
410       return Response.status(SC_BAD_REQUEST).build();
411     } catch (UnauthorizedException ex) {
412       return Response.status(SC_FORBIDDEN).build();
413     } catch (NotFoundException e) {
414       return ApiResponseBuilder.notFound("Cannot find group with id '%s'.", id);
415     } catch (Exception e) {
416       logger.warn("Could not update the group with id {}.", id, e);
417       return ApiResponseBuilder.serverError("Could not update group with id '%s', reason: '%s'", id, getMessage(e));
418     }
419   }
420 }