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.data.json.Jsons.arr;
24  import static com.entwinemedia.fn.data.json.Jsons.f;
25  import static com.entwinemedia.fn.data.json.Jsons.obj;
26  import static com.entwinemedia.fn.data.json.Jsons.v;
27  import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
28  import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
29  import static javax.servlet.http.HttpServletResponse.SC_CREATED;
30  import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
31  import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
32  import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
33  import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
34  import static javax.servlet.http.HttpServletResponse.SC_OK;
35  import static org.apache.commons.lang3.StringUtils.join;
36  import static org.apache.commons.lang3.exception.ExceptionUtils.getMessage;
37  import static org.opencastproject.external.common.ApiVersion.VERSION_1_6_0;
38  import static org.opencastproject.util.doc.rest.RestParameter.Type.STRING;
39  
40  import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
41  import org.opencastproject.external.common.ApiMediaType;
42  import org.opencastproject.external.common.ApiResponseBuilder;
43  import org.opencastproject.external.common.ApiVersion;
44  import org.opencastproject.index.service.resources.list.query.GroupsListQuery;
45  import org.opencastproject.index.service.util.RestUtils;
46  import org.opencastproject.security.api.SecurityService;
47  import org.opencastproject.security.api.UnauthorizedException;
48  import org.opencastproject.security.impl.jpa.JpaGroup;
49  import org.opencastproject.userdirectory.ConflictException;
50  import org.opencastproject.userdirectory.JpaGroupRoleProvider;
51  import org.opencastproject.util.NotFoundException;
52  import org.opencastproject.util.doc.rest.RestParameter;
53  import org.opencastproject.util.doc.rest.RestQuery;
54  import org.opencastproject.util.doc.rest.RestResponse;
55  import org.opencastproject.util.doc.rest.RestService;
56  import org.opencastproject.util.requests.SortCriterion;
57  
58  import com.entwinemedia.fn.data.json.Field;
59  import com.entwinemedia.fn.data.json.JValue;
60  import com.entwinemedia.fn.data.json.Jsons;
61  
62  import org.apache.commons.collections4.ComparatorUtils;
63  import org.apache.commons.lang3.StringUtils;
64  import org.osgi.service.component.annotations.Activate;
65  import org.osgi.service.component.annotations.Component;
66  import org.osgi.service.component.annotations.Reference;
67  import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
68  import org.slf4j.Logger;
69  import org.slf4j.LoggerFactory;
70  
71  import java.util.ArrayList;
72  import java.util.Collections;
73  import java.util.Comparator;
74  import java.util.HashMap;
75  import java.util.HashSet;
76  import java.util.List;
77  import java.util.Map;
78  import java.util.Optional;
79  import java.util.Set;
80  import java.util.stream.Collectors;
81  
82  import javax.servlet.http.HttpServletResponse;
83  import javax.ws.rs.DELETE;
84  import javax.ws.rs.FormParam;
85  import javax.ws.rs.GET;
86  import javax.ws.rs.HeaderParam;
87  import javax.ws.rs.POST;
88  import javax.ws.rs.PUT;
89  import javax.ws.rs.Path;
90  import javax.ws.rs.PathParam;
91  import javax.ws.rs.Produces;
92  import javax.ws.rs.QueryParam;
93  import javax.ws.rs.WebApplicationException;
94  import javax.ws.rs.core.Response;
95  
96  @Path("/api/groups")
97  @Produces({ ApiMediaType.JSON, ApiMediaType.VERSION_1_0_0, ApiMediaType.VERSION_1_1_0, ApiMediaType.VERSION_1_2_0,
98              ApiMediaType.VERSION_1_3_0, ApiMediaType.VERSION_1_4_0, ApiMediaType.VERSION_1_5_0,
99              ApiMediaType.VERSION_1_6_0, ApiMediaType.VERSION_1_7_0, ApiMediaType.VERSION_1_8_0,
100             ApiMediaType.VERSION_1_9_0, ApiMediaType.VERSION_1_10_0, ApiMediaType.VERSION_1_11_0 })
101 @RestService(name = "externalapigroups", title = "External API Groups Service", notes = {}, abstractText = "Provides resources and operations related to the groups")
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(name = "getgroups", description = "Returns a list of groups.", returnDescription = "", restParameters = {
149           @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),
150           @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),
151           @RestParameter(name = "limit", description = "The maximum number of results to return for a single request.", isRequired = false, type = RestParameter.Type.INTEGER),
152           @RestParameter(name = "offset", description = "The index of the first result to return.", isRequired = false, type = RestParameter.Type.INTEGER) }, responses = {
153                   @RestResponse(description = "A (potentially empty) list of groups.", responseCode = HttpServletResponse.SC_OK) })
154   public Response getGroups(@HeaderParam("Accept") String acceptHeader, @QueryParam("filter") String filter,
155           @QueryParam("sort") String sort, @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit) {
156     final ApiVersion requestedVersion = ApiMediaType.parse(acceptHeader).getVersion();
157     Optional<Integer> optLimit = Optional.ofNullable(limit);
158     Optional<Integer> optOffset = Optional.ofNullable(offset);
159 
160     if (optLimit.isPresent() && limit <= 0) {
161       optLimit = Optional.empty();
162     }
163     if (optOffset.isPresent() && offset < 0) {
164       optOffset = Optional.empty();
165     }
166 
167     // The API currently does not offer full text search for groups
168     Map<String, String> filters = new HashMap<>();
169     if (StringUtils.isNotBlank(filter)) {
170       for (String f : filter.split(",")) {
171         String[] filterTuple = f.split(":");
172         if (filterTuple.length < 2) {
173           logger.debug("No value for filter '{}' in filters list: {}", filterTuple[0], filter);
174           continue;
175         }
176         // use substring because dates also contain : so there might be more than two parts
177         filters.put(filterTuple[0].trim(), f.substring(filterTuple[0].length() + 1).trim());
178       }
179     }
180     Optional<String> optNameFilter = Optional.ofNullable(filters.get(GroupsListQuery.FILTER_NAME_NAME));
181 
182     ArrayList<SortCriterion> sortCriteria = RestUtils.parseSortQueryParameter(sort);
183 
184     // sorting by members & roles is not supported by the database, so we do this afterwards
185     Set<SortCriterion> deprecatedSortCriteria = new HashSet<>();
186     if (requestedVersion.isSmallerThan(VERSION_1_6_0)) {
187       deprecatedSortCriteria = sortCriteria.stream().filter(
188               sortCriterion -> sortCriterion.getFieldName().equals("members")
189                       || sortCriterion.getFieldName().equals("roles")).collect(Collectors.toSet());
190       sortCriteria.removeAll(deprecatedSortCriteria);
191     }
192 
193     List<JpaGroup> results = jpaGroupRoleProvider.getGroups(optLimit, optOffset, optNameFilter, Optional.empty(),
194             sortCriteria);
195 
196     // sorting by members & roles is only available for api versions < 1.6.0
197     if (requestedVersion.isSmallerThan(VERSION_1_6_0)) {
198       List<Comparator<JpaGroup>> comparators = new ArrayList<>();
199       for (SortCriterion sortCriterion : deprecatedSortCriteria) {
200         Comparator<JpaGroup> comparator;
201         switch (sortCriterion.getFieldName()) {
202           case "members":
203             comparator = new GroupComparator() {
204               @Override
205               public Set<String> getGroupAttribute(JpaGroup jpaGroup) {
206                 return jpaGroup.getMembers();
207               }
208             };
209             break;
210           case "roles":
211             comparator = new GroupComparator() {
212               @Override
213               public Set<String> getGroupAttribute(JpaGroup jpaGroup) {
214                 return jpaGroup.getRoleNames();
215               }
216             };
217             break;
218           default:
219             continue;
220         }
221 
222         if (sortCriterion.getOrder() == SortCriterion.Order.Descending) {
223           comparator = comparator.reversed();
224         }
225         comparators.add(comparator);
226       }
227       Collections.sort(results, ComparatorUtils.chainedComparator(comparators));
228     }
229 
230     List<JValue> groupsJSON = new ArrayList<>();
231     for (JpaGroup group : results) {
232       List<Field> fields = new ArrayList<>();
233       fields.add(f("identifier", v(group.getGroupId())));
234       fields.add(f("organization", v(group.getOrganization().getId())));
235       fields.add(f("role", v(group.getRole())));
236       fields.add(f("name", v(group.getName(), Jsons.BLANK)));
237       fields.add(f("description", v(group.getDescription(), Jsons.BLANK)));
238       fields.add(f("roles", v(join(group.getRoleNames(), ","), Jsons.BLANK)));
239       fields.add(f("members", v(join(group.getMembers(), ","), Jsons.BLANK)));
240       groupsJSON.add(obj(fields));
241     }
242     return ApiResponseBuilder.Json.ok(acceptHeader, arr(groupsJSON));
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     return ApiResponseBuilder.Json.ok(acceptHeader,
283             obj(
284                     f("identifier", v(group.getGroupId())),
285                     f("organization", v(group.getOrganization().getId())),  f("role", v(group.getRole())),
286                     f("name", v(group.getName(), Jsons.BLANK)),
287                     f("description", v(group.getDescription(), Jsons.BLANK)),
288                     f("roles", v(join(group.getRoleNames(), ","), Jsons.BLANK)),
289                     f("members", v(join(group.getMembers(), ","), Jsons.BLANK))
290             )
291     );
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 }