]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-8894 Search members of an organization WS api/organizations/search_members
authorTeryk Bellahsene <teryk.bellahsene@sonarsource.com>
Fri, 17 Mar 2017 17:11:43 +0000 (18:11 +0100)
committerJulien Lancelot <julien.lancelot@sonarsource.com>
Tue, 21 Mar 2017 12:05:50 +0000 (13:05 +0100)
17 files changed:
server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberMapper.java
server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipMapper.java
server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMemberMapper.xml
server/sonar-db-dao/src/main/resources/org/sonar/db/user/GroupMembershipMapper.xml
server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationMemberDaoTest.java
server/sonar-db-dao/src/test/java/org/sonar/db/user/GroupMembershipDaoTest.java
server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java
server/sonar-server/src/main/java/org/sonar/server/user/index/UserIndex.java
server/sonar-server/src/main/java/org/sonar/server/user/index/UserQuery.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/user/ws/SearchAction.java
server/sonar-server/src/main/resources/org/sonar/server/organization/ws/search_members-example.json
server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchMembersActionTest.java
server/sonar-server/src/test/java/org/sonar/server/user/index/UserIndexTest.java
sonar-ws/src/main/java/org/sonarqube/ws/client/organization/SearchMembersWsRequest.java [new file with mode: 0644]
sonar-ws/src/main/protobuf/ws-organizations.proto

index ebe6b5559adf8925c5e60f286bd37e5cee177f17..6a10a129ab42b675d5a55f796b8e51b538848633 100644 (file)
@@ -20,6 +20,7 @@
 
 package org.sonar.db.organization;
 
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 import org.sonar.db.Dao;
@@ -34,6 +35,10 @@ public class OrganizationMemberDao implements Dao {
     return Optional.ofNullable(mapper(dbSession).select(organizationUuid, userId));
   }
 
+  public List<String> selectLoginsByOrganizationUuid(DbSession dbSession, String organizationUuid) {
+    return mapper(dbSession).selectLogins(organizationUuid);
+  }
+
   public void insert(DbSession dbSession, OrganizationMemberDto organizationMemberDto) {
     mapper(dbSession).insert(organizationMemberDto);
   }
index 5f964aaaaaf43c4c648982b3cbc690968def2f75..80237b276d16dc62c7c96a56e4a306e07d4d1deb 100644 (file)
@@ -20,6 +20,7 @@
 
 package org.sonar.db.organization;
 
+import java.util.List;
 import java.util.Set;
 import org.apache.ibatis.annotations.Param;
 
@@ -28,6 +29,8 @@ public interface OrganizationMemberMapper {
 
   Set<String> selectOrganizationUuidsByUser(@Param("userId") int userId);
 
+  List<String> selectLogins(String organizationUuid);
+
   void insert(OrganizationMemberDto organizationMember);
 
   void delete(@Param("organizationUuid") String organizationUuid, @Param("userId") Integer userId);
index bde120c61677ebd2305bbccf8f25ba11116bcad8..4c30022d770a1ce95f1fb590ca0ad386cba1d303 100644 (file)
@@ -23,6 +23,7 @@ import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
+import com.google.common.collect.Multiset;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -73,6 +74,21 @@ public class GroupMembershipDao implements Dao {
     return mapper(dbSession).selectGroupIdsByUserId(userId);
   }
 
+  public Multiset<String> countGroupByLoginsAndOrganization(DbSession dbSession, Collection<String> logins, String organizationUuid) {
+    Multimap<String, String> result = ArrayListMultimap.create();
+    executeLargeInputs(
+      logins,
+      input -> {
+        List<LoginGroup> groupMemberships = mapper(dbSession).selectGroupsByLoginsAndOrganization(input, organizationUuid);
+        for (LoginGroup membership : groupMemberships) {
+          result.put(membership.login(), membership.groupName());
+        }
+        return groupMemberships;
+      });
+
+    return result.keys();
+  }
+
   public Multimap<String, String> selectGroupsByLogins(DbSession session, Collection<String> logins) {
     Multimap<String, String> result = ArrayListMultimap.create();
     executeLargeInputs(
index 6bf021b158e99fc69567ca0787f9b482ed2edb49..03672f133eb46a8e5ee6c2f20ec2643a2e3543f3 100644 (file)
@@ -38,6 +38,7 @@ public interface GroupMembershipMapper {
 
   List<LoginGroup> selectGroupsByLogins(@Param("logins") List<String> logins);
 
+  List<LoginGroup> selectGroupsByLoginsAndOrganization(@Param("logins") List<String> logins, @Param("organizationUuid") String organizationUuid);
+
   List<Integer> selectGroupIdsByUserId(@Param("userId") int userId);
-  
 }
index 2869578802963d59a421e82d6603ccb3548d1a72..2a63ade892fa361e3080f58e718f470d5233b9b8 100644 (file)
     and om.user_id = #{userId, jdbcType=INTEGER}
   </select>
 
+  <select id="selectLogins" resultType="string">
+    select u.login
+    from organization_members om
+      inner join users u on om.user_id = u.id
+    where om.organization_uuid=#{organizationUuid,jdbcType=VARCHAR}
+  </select>
+
   <select id="selectOrganizationUuidsByUser" resultType="String">
     select om.organization_uuid as "organizationUuid"
     from organization_members om
@@ -36,7 +43,7 @@
     )
   </insert>
 
-  <delete id="delete" parameterType="map">
+  <delete id="delete">
     delete from organization_members
     where
     organization_uuid = #{organizationUuid, jdbcType=VARCHAR}
index 7eb8c8353469c906cc7571cf59e65ceb938b0bc2..57bbdba073c08986158b991ef54470508f8b7484 100644 (file)
     ORDER BY u.login, g.name, g.id
   </select>
 
+  <select id="selectGroupsByLoginsAndOrganization" parameterType="map" resultType="org.sonar.db.user.LoginGroup">
+    SELECT u.login as login, g.name as groupName
+    FROM users u
+    LEFT JOIN groups_users gu ON gu.user_id=u.id
+    INNER JOIN groups g ON gu.group_id=g.id
+    <where>
+      u.login in
+      <foreach collection="logins" open="(" close=")" item="login" separator=",">
+        #{login}
+      </foreach>
+      and g.organization_uuid=#{organizationUuid,jdbcType=VARCHAR}
+    </where>
+    ORDER BY u.login, g.name, g.id
+  </select>
+
   <sql id="userCommonClauses">
     FROM users u
     LEFT JOIN groups_users gu ON gu.user_id=u.id AND gu.group_id=#{groupId}
index 893764f132133f7032b1e7934edac4057f662ff0..a586f1be28b80912ebbc0380afef977f7eb57181 100644 (file)
@@ -20,6 +20,7 @@
 
 package org.sonar.db.organization;
 
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.apache.ibatis.exceptions.PersistenceException;
@@ -29,6 +30,7 @@ import org.junit.rules.ExpectedException;
 import org.sonar.db.DbClient;
 import org.sonar.db.DbSession;
 import org.sonar.db.DbTester;
+import org.sonar.db.user.UserDto;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.entry;
@@ -56,6 +58,22 @@ public class OrganizationMemberDaoTest {
     assertThat(underTest.select(dbSession, "O2", 512)).isNotPresent();
   }
 
+  @Test
+  public void select_logins() {
+    OrganizationDto organization = db.organizations().insert();
+    OrganizationDto anotherOrganization = db.organizations().insert();
+    UserDto user = db.users().insertUser();
+    UserDto anotherUser = db.users().insertUser();
+    UserDto userInAnotherOrganization = db.users().insertUser();
+    db.organizations().addMember(organization, user);
+    db.organizations().addMember(organization, anotherUser);
+    db.organizations().addMember(anotherOrganization, userInAnotherOrganization);
+
+    List<String> result = underTest.selectLoginsByOrganizationUuid(dbSession, organization.getUuid());
+
+    assertThat(result).containsOnly(user.getLogin(), anotherUser.getLogin());
+  }
+
   @Test
   public void select_organization_uuids_by_user_id() {
     OrganizationDto organizationDto1 = db.organizations().insert();
index b9f89695ffb6266420f529d68ec393a101037e06..2f43d70e29b9ba8a90c87c027670561b51432199 100644 (file)
@@ -63,7 +63,7 @@ public class GroupMembershipDaoTest {
   }
 
   @Test
-  public void count_groups_by_login() {
+  public void count_groups_by_logins() {
     dbTester.prepareDbUnit(getClass(), "shared.xml");
 
     assertThat(underTest.selectGroupsByLogins(dbTester.getSession(), Arrays.<String>asList()).keys()).isEmpty();
index 764ce85ca8382fe3fdb3d91a31880e39dbcc9c0a..77208d0f7a28c1cab8735d8cd57b49bcca05da51 100644 (file)
 
 package org.sonar.server.organization.ws;
 
+import com.google.common.collect.Multiset;
+import com.google.common.collect.Ordering;
+import com.google.common.hash.Hashing;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import javax.annotation.Nullable;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
 import org.sonar.api.server.ws.WebService;
+import org.sonar.api.server.ws.WebService.Param;
+import org.sonar.api.server.ws.WebService.SelectionMode;
+import org.sonar.core.util.stream.Collectors;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.organization.OrganizationDto;
+import org.sonar.db.permission.OrganizationPermission;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.es.SearchOptions;
+import org.sonar.server.es.SearchResult;
+import org.sonar.server.organization.DefaultOrganizationProvider;
+import org.sonar.server.user.UserSession;
+import org.sonar.server.user.index.UserDoc;
+import org.sonar.server.user.index.UserIndex;
+import org.sonar.server.user.index.UserQuery;
+import org.sonarqube.ws.Common;
+import org.sonarqube.ws.Organizations.SearchMembersWsResponse;
+import org.sonarqube.ws.Organizations.User;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static org.sonar.core.util.Protobuf.setNullable;
 import static org.sonar.server.es.SearchOptions.MAX_LIMIT;
+import static org.sonar.server.ws.WsUtils.checkFoundWithOptional;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
 
 public class SearchMembersAction implements OrganizationsWsAction {
+
+  private final DbClient dbClient;
+  private final UserIndex userIndex;
+  private final DefaultOrganizationProvider organizationProvider;
+  private final UserSession userSession;
+
+  public SearchMembersAction(DbClient dbClient, UserIndex userIndex, DefaultOrganizationProvider organizationProvider, UserSession userSession) {
+    this.dbClient = dbClient;
+    this.userIndex = userIndex;
+    this.organizationProvider = organizationProvider;
+    this.userSession = userSession;
+  }
+
   @Override
   public void define(WebService.NewController context) {
     WebService.NewAction action = context.createAction("search_members")
+      .setDescription("Search members of an organization")
       .setResponseExample(getClass().getResource("search_members-example.json"))
       .setSince("6.4")
       .setInternal(true)
       .setHandler(this);
 
-    action.addSelectionModeParam();
-    action.addSearchQuery("freddy", "names", "logins");
+    action.addSearchQuery("orwe", "names", "logins");
     action.addPagingParams(50, MAX_LIMIT);
 
+    action.createParam(Param.SELECTED)
+      .setDescription("Depending on the value, show only selected items (selected=selected) or deselected items (selected=deselected).")
+      .setInternal(true)
+      .setDefaultValue(SelectionMode.SELECTED.value())
+      .setPossibleValues(SelectionMode.SELECTED.value(), SelectionMode.DESELECTED.value());
+
     action.createParam("organization")
       .setDescription("Organization key")
       .setInternal(true)
@@ -47,6 +96,92 @@ public class SearchMembersAction implements OrganizationsWsAction {
 
   @Override
   public void handle(Request request, Response response) throws Exception {
-    // TODO
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      OrganizationDto organization = getOrganization(dbSession, request.param("organization"));
+      List<String> memberLogins = dbClient.organizationMemberDao().selectLoginsByOrganizationUuid(dbSession, organization.getUuid());
+
+      UserQuery.Builder userQuery = buildUserQuery(request, memberLogins);
+      SearchOptions searchOptions = buildSearchOptions(request);
+
+      SearchResult<UserDoc> searchResults = userIndex.search(userQuery.build(), searchOptions);
+      List<String> orderedLogins = searchResults.getDocs().stream().map(UserDoc::login).collect(Collectors.toList());
+
+      List<UserDto> users = dbClient.userDao().selectByLogins(dbSession, orderedLogins).stream()
+        .sorted(Ordering.explicit(orderedLogins).onResultOf(UserDto::getLogin))
+        .collect(Collectors.toList());
+
+      Multiset<String> groupCountByLogin = null;
+      if (userSession.hasPermission(OrganizationPermission.ADMINISTER, organization)) {
+        groupCountByLogin = dbClient.groupMembershipDao().countGroupByLoginsAndOrganization(dbSession, orderedLogins, organization.getUuid());
+      }
+
+      Common.Paging wsPaging = buildWsPaging(request, searchResults);
+      SearchMembersWsResponse wsResponse = buildResponse(users, wsPaging, groupCountByLogin);
+
+      writeProtobuf(wsResponse, request, response);
+    }
+  }
+
+  private static SearchMembersWsResponse buildResponse(List<UserDto> users, Common.Paging wsPaging, @Nullable Multiset<String> groupCountByLogin) {
+    SearchMembersWsResponse.Builder response = SearchMembersWsResponse.newBuilder();
+
+    User.Builder wsUser = User.newBuilder();
+    users.stream()
+      .map(userDto -> {
+        String login = userDto.getLogin();
+        wsUser
+          .clear()
+          .setLogin(login)
+          .setName(userDto.getName());
+        setNullable(userDto.getEmail(), text -> wsUser.setAvatar(hash(text)));
+        setNullable(groupCountByLogin, count -> wsUser.setGroupCount(groupCountByLogin.count(login)));
+        return wsUser;
+      })
+      .forEach(response::addUsers);
+    response.setPaging(wsPaging);
+
+    return response.build();
+  }
+
+  private static UserQuery.Builder buildUserQuery(Request request, List<String> memberLogins) {
+    UserQuery.Builder userQuery = UserQuery.builder();
+    String textQuery = request.param(Param.TEXT_QUERY);
+    checkArgument(textQuery == null || textQuery.length() >= 2, "Query length must be greater than or equal to 2");
+    userQuery.setTextQuery(textQuery);
+
+    SelectionMode selectionMode = SelectionMode.fromParam(request.mandatoryParam(Param.SELECTED));
+    if (SelectionMode.DESELECTED.equals(selectionMode)) {
+      userQuery.setExcludedLogins(memberLogins);
+    } else {
+      userQuery.setLogins(memberLogins);
+    }
+    return userQuery;
+  }
+
+  private static SearchOptions buildSearchOptions(Request request) {
+    int pageSize = request.mandatoryParamAsInt(Param.PAGE_SIZE);
+    checkArgument(pageSize <= SearchOptions.MAX_LIMIT, "Page size must lower than or equal to %s", SearchOptions.MAX_LIMIT);
+
+    return new SearchOptions().setPage(request.mandatoryParamAsInt(Param.PAGE), pageSize);
+  }
+
+  private static String hash(String text) {
+    return Hashing.md5().hashString(text.toLowerCase(Locale.ENGLISH), StandardCharsets.UTF_8).toString();
+  }
+
+  private static Common.Paging buildWsPaging(Request request, SearchResult<UserDoc> searchResults) {
+    return Common.Paging.newBuilder()
+      .setPageIndex(request.mandatoryParamAsInt(Param.PAGE))
+      .setPageSize(request.mandatoryParamAsInt(Param.PAGE_SIZE))
+      .setTotal((int) searchResults.getTotal())
+      .build();
+  }
+
+  private OrganizationDto getOrganization(DbSession dbSession, @Nullable String organizationParam) {
+    String organizationKey = Optional.ofNullable(organizationParam)
+      .orElseGet(organizationProvider.get()::getKey);
+    return checkFoundWithOptional(
+      dbClient.organizationDao().selectByKey(dbSession, organizationKey),
+      "No organization with key '%s'", organizationKey);
   }
 }
index 2881629c838fd4991bce7769f88084e3720d02ec..a3a17feb13d738cf02099811ffa3c01ff014ccae 100644 (file)
@@ -24,8 +24,8 @@ import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import javax.annotation.CheckForNull;
-import javax.annotation.Nullable;
 import org.apache.commons.lang.StringUtils;
 import org.elasticsearch.action.get.GetRequestBuilder;
 import org.elasticsearch.action.get.GetResponse;
@@ -133,20 +133,24 @@ public class UserIndex {
     return EsUtils.scroll(esClient, response.getScrollId(), DOC_CONVERTER);
   }
 
-  public SearchResult<UserDoc> search(@Nullable String searchText, SearchOptions options) {
+  public SearchResult<UserDoc> search(UserQuery userQuery, SearchOptions options) {
     SearchRequestBuilder request = esClient.prepareSearch(UserIndexDefinition.INDEX_TYPE_USER)
       .setSize(options.getLimit())
       .setFrom(options.getOffset())
       .addSort(FIELD_NAME, SortOrder.ASC);
 
-    BoolQueryBuilder filter = boolQuery()
-      .must(termQuery(FIELD_ACTIVE, true));
+    BoolQueryBuilder filter = boolQuery().must(termQuery(FIELD_ACTIVE, true));
+
+    userQuery.getLogins().ifPresent(
+      logins -> filter.must(termsQuery(FIELD_LOGIN, userQuery.getLogins().get())));
+
+    userQuery.getExcludedLogins().ifPresent(
+      excludedLogins -> filter.mustNot(termsQuery(FIELD_LOGIN, userQuery.getExcludedLogins().get())));
 
-    QueryBuilder query;
-    if (StringUtils.isEmpty(searchText)) {
-      query = matchAllQuery();
-    } else {
-      query = QueryBuilders.multiMatchQuery(searchText,
+    QueryBuilder esQuery = matchAllQuery();
+    Optional<String> textQuery = userQuery.getTextQuery();
+    if (textQuery.isPresent()) {
+      esQuery = QueryBuilders.multiMatchQuery(textQuery.get(),
         FIELD_LOGIN,
         USER_SEARCH_GRAMS_ANALYZER.subField(FIELD_LOGIN),
         FIELD_NAME,
@@ -156,7 +160,7 @@ public class UserIndex {
         .operator(MatchQueryBuilder.Operator.AND);
     }
 
-    request.setQuery(boolQuery().must(query).filter(filter));
+    request.setQuery(boolQuery().must(esQuery).filter(filter));
 
     return new SearchResult<>(request.get(), DOC_CONVERTER);
   }
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/index/UserQuery.java b/server/sonar-server/src/main/java/org/sonar/server/user/index/UserQuery.java
new file mode 100644 (file)
index 0000000..9ff05e6
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+package org.sonar.server.user.index;
+
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.apache.commons.lang.StringUtils.isBlank;
+
+@Immutable
+public class UserQuery {
+  private final String textQuery;
+  private final List<String> logins;
+  private final List<String> excludedLogins;
+
+  private UserQuery(Builder builder) {
+    this.textQuery = builder.textQuery;
+    this.logins = builder.logins == null ? null : ImmutableList.copyOf(builder.logins);
+    this.excludedLogins = builder.excludedLogins == null ? null : ImmutableList.copyOf(builder.excludedLogins);
+  }
+
+  public Optional<String> getTextQuery() {
+    return Optional.ofNullable(textQuery);
+  }
+
+  public Optional<List<String>> getLogins() {
+    return Optional.ofNullable(logins);
+  }
+
+  public Optional<List<String>> getExcludedLogins() {
+    return Optional.ofNullable(excludedLogins);
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+    private String textQuery;
+    private List<String> logins;
+    private List<String> excludedLogins;
+
+    private Builder() {
+      // enforce factory method
+    }
+
+    public UserQuery build() {
+      return new UserQuery(this);
+    }
+
+    public Builder setTextQuery(@Nullable String textQuery) {
+      this.textQuery = isBlank(textQuery) ? null : textQuery;
+      return this;
+    }
+
+    public Builder setLogins(@Nullable List<String> logins) {
+      this.logins = logins;
+      return this;
+    }
+
+    public Builder setExcludedLogins(@Nullable List<String> excludedLogins) {
+      this.excludedLogins = excludedLogins;
+      return this;
+    }
+  }
+}
index 1a0afb35b63e4086e7f2ce08d57860a92f4f12e0..4fd0d0da7615ffee3a5493e3ca4e03da0876094b 100644 (file)
@@ -39,6 +39,7 @@ import org.sonar.server.es.SearchOptions;
 import org.sonar.server.es.SearchResult;
 import org.sonar.server.user.index.UserDoc;
 import org.sonar.server.user.index.UserIndex;
+import org.sonar.server.user.index.UserQuery;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static org.sonar.server.es.SearchOptions.MAX_LIMIT;
@@ -78,7 +79,8 @@ public class SearchAction implements UsersWsAction {
     SearchOptions options = new SearchOptions()
       .setPage(request.mandatoryParamAsInt(Param.PAGE), request.mandatoryParamAsInt(Param.PAGE_SIZE));
     List<String> fields = request.paramAsStrings(Param.FIELDS);
-    SearchResult<UserDoc> result = userIndex.search(request.param(Param.TEXT_QUERY), options);
+    String textQuery = request.param(Param.TEXT_QUERY);
+    SearchResult<UserDoc> result = userIndex.search(UserQuery.builder().setTextQuery(textQuery).build(), options);
 
     try (DbSession dbSession = dbClient.openSession(false)) {
       List<String> logins = Lists.transform(result.getDocs(), UserDocToLogin.INSTANCE);
index 2c63c0851048d8f7bff41ecf0f8cee05f52fd120..26b7786c7a7292158c272acab53de3f3f15497cc 100644 (file)
@@ -1,2 +1,19 @@
 {
+  "users": [
+    {
+      "login": "ada.lovelace",
+      "name": "Ada Lovelace",
+      "avatar": "680b0001b4952664631511a81a4edc59"
+    },
+    {
+      "login": "grace.hopper",
+      "name": "Grace Hopper",
+      "avatar": "36d90b0b8cc8639e4960e46116dce02c"
+    }
+  ],
+  "paging": {
+    "pageIndex": 1,
+    "pageSize": 50,
+    "total": 2
+  }
 }
index 0a79b6372750aa5945b25ff0304449400608e1a6..1e61a2718a880ceb760574f37b075f38f7c811a4 100644 (file)
 
 package org.sonar.server.organization.ws;
 
+import com.google.common.base.Throwables;
+import java.io.IOException;
+import java.util.stream.IntStream;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.config.MapSettings;
 import org.sonar.api.server.ws.WebService;
+import org.sonar.api.server.ws.WebService.Param;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbTester;
+import org.sonar.db.organization.OrganizationDto;
+import org.sonar.db.permission.OrganizationPermission;
+import org.sonar.db.user.GroupDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.es.EsTester;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.organization.DefaultOrganizationProvider;
+import org.sonar.server.organization.TestDefaultOrganizationProvider;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.user.index.UserIndex;
+import org.sonar.server.user.index.UserIndexDefinition;
+import org.sonar.server.user.index.UserIndexer;
+import org.sonar.server.ws.TestRequest;
 import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Common.Paging;
+import org.sonarqube.ws.MediaTypes;
+import org.sonarqube.ws.Organizations.SearchMembersWsResponse;
+import org.sonarqube.ws.Organizations.User;
+import org.sonarqube.ws.client.organization.SearchMembersWsRequest;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.sonar.core.util.Protobuf.setNullable;
+import static org.sonar.test.JsonAssert.assertJson;
 
 public class SearchMembersActionTest {
 
-  private WsActionTester ws = new WsActionTester(new SearchMembersAction());
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+  @Rule
+  public EsTester es = new EsTester(new UserIndexDefinition(new MapSettings()));
+
+  @Rule
+  public DbTester db = DbTester.create();
+  private DbClient dbClient = db.getDbClient();
+
+  private DefaultOrganizationProvider organizationProvider = TestDefaultOrganizationProvider.from(db);
+  private UserIndexer indexer = new UserIndexer(dbClient, es.client());
+
+  private WsActionTester ws = new WsActionTester(new SearchMembersAction(dbClient, new UserIndex(es.client()), organizationProvider, userSession));
+
+  private SearchMembersWsRequest request = new SearchMembersWsRequest();
+
+  @Test
+  public void empty_response() {
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList()).isEmpty();
+    assertThat(result.getPaging())
+      .extracting(Paging::getPageIndex, Paging::getPageSize, Paging::getTotal)
+      .containsExactly(1, 50, 0);
+  }
+
+  @Test
+  public void search_members_of_default_organization() {
+    OrganizationDto defaultOrganization = db.getDefaultOrganization();
+    OrganizationDto anotherOrganization = db.organizations().insert();
+    UserDto user = insertUser();
+    UserDto anotherUser = insertUser();
+    UserDto userInAnotherOrganization = insertUser();
+    db.organizations().addMember(defaultOrganization, user);
+    db.organizations().addMember(defaultOrganization, anotherUser);
+    db.organizations().addMember(anotherOrganization, userInAnotherOrganization);
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList())
+      .extracting(User::getLogin, User::getName)
+      .containsOnly(
+        tuple(user.getLogin(), user.getName()),
+        tuple(anotherUser.getLogin(), anotherUser.getName()));
+  }
+
+  @Test
+  public void search_members_of_specified_organization() {
+    OrganizationDto organization = db.organizations().insert();
+    OrganizationDto anotherOrganization = db.organizations().insert();
+    UserDto user = insertUser();
+    UserDto anotherUser = insertUser();
+    UserDto userInAnotherOrganization = insertUser();
+    db.organizations().addMember(organization, user);
+    db.organizations().addMember(organization, anotherUser);
+    db.organizations().addMember(anotherOrganization, userInAnotherOrganization);
+    request.setOrganization(organization.getKey());
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList())
+      .extracting(User::getLogin, User::getName)
+      .containsOnly(
+        tuple(user.getLogin(), user.getName()),
+        tuple(anotherUser.getLogin(), anotherUser.getName()));
+  }
+
+  @Test
+  public void return_avatar() {
+    UserDto user = db.users().insertUser(u -> u.setEmail("email@domain.com"));
+    indexer.index(user.getLogin());
+    db.organizations().addMember(db.getDefaultOrganization(), user);
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsers(0).getAvatar()).isEqualTo("7328fddefd53de471baeb6e2b764f78a");
+  }
+
+  @Test
+  public void do_not_return_group_count_if_no_admin_permission() {
+    UserDto user = insertUser();
+    GroupDto group = db.users().insertGroup();
+    db.users().insertMember(group, user);
+    db.organizations().addMember(db.getDefaultOrganization(), user);
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsers(0).hasGroupCount()).isFalse();
+  }
+
+  @Test
+  public void return_group_counts_if_org_admin() {
+    userSession.addPermission(OrganizationPermission.ADMINISTER, db.getDefaultOrganization());
+    UserDto user = insertUser();
+    UserDto anotherUser = insertUser();
+    IntStream.range(0, 10)
+      .mapToObj(i -> db.users().insertGroup())
+      .forEach(g -> db.users().insertMembers(g, user));
+    OrganizationDto anotherOrganization = db.organizations().insert();
+    GroupDto anotherGroup = db.users().insertGroup(anotherOrganization);
+    db.users().insertMember(anotherGroup, user);
+    db.organizations().addMember(db.getDefaultOrganization(), user);
+    db.organizations().addMember(db.getDefaultOrganization(), anotherUser);
+    db.organizations().addMember(anotherOrganization, user);
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList()).extracting(User::getLogin, User::getGroupCount).containsOnly(
+      tuple(user.getLogin(), 10),
+      tuple(anotherUser.getLogin(), 0));
+  }
+
+  @Test
+  public void search_non_members() {
+    OrganizationDto defaultOrganization = db.getDefaultOrganization();
+    OrganizationDto anotherOrganization = db.organizations().insert();
+    UserDto user = insertUser();
+    UserDto anotherUser = insertUser();
+    UserDto userInAnotherOrganization = insertUser();
+    db.organizations().addMember(anotherOrganization, user);
+    db.organizations().addMember(anotherOrganization, anotherUser);
+    db.organizations().addMember(defaultOrganization, userInAnotherOrganization);
+    request.setSelected(WebService.SelectionMode.DESELECTED.value());
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList())
+      .extracting(User::getLogin, User::getName)
+      .containsOnly(
+        tuple(user.getLogin(), user.getName()),
+        tuple(anotherUser.getLogin(), anotherUser.getName()));
+  }
+
+  @Test
+  public void search_members_pagination() {
+    IntStream.range(0, 10).forEach(i -> {
+      UserDto userDto = db.users().insertUser(user -> user.setName("USER_" + i));
+      db.organizations().addMember(db.getDefaultOrganization(), userDto);
+      indexer.index(userDto.getLogin());
+    });
+    request.setPage(2).setPageSize(3);
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList()).extracting(User::getName)
+      .containsExactly("USER_3", "USER_4", "USER_5");
+    assertThat(result.getPaging())
+      .extracting(Paging::getPageIndex, Paging::getPageSize, Paging::getTotal)
+      .containsExactly(2, 3, 10);
+  }
+
+  @Test
+  public void search_members_by_name() {
+    IntStream.range(0, 10).forEach(i -> {
+      UserDto userDto = db.users().insertUser(user -> user.setName("USER_" + i));
+      db.organizations().addMember(db.getDefaultOrganization(), userDto);
+      indexer.index(userDto.getLogin());
+    });
+    request.setQuery("_9");
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList()).extracting(User::getName).containsExactly("USER_9");
+  }
+
+  @Test
+  public void search_members_by_login() {
+    IntStream.range(0, 10).forEach(i -> {
+      UserDto userDto = db.users().insertUser(user -> user.setLogin("USER_" + i));
+      db.organizations().addMember(db.getDefaultOrganization(), userDto);
+      indexer.index(userDto.getLogin());
+    });
+    request.setQuery("_9");
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList()).extracting(User::getLogin).containsExactly("USER_9");
+  }
+
+  @Test
+  public void search_members_by_email() {
+    IntStream.range(0, 10).forEach(i -> {
+      UserDto userDto = db.users().insertUser(user -> user
+        .setLogin("L" + i)
+        .setEmail("USER_" + i + "@email.com"));
+      db.organizations().addMember(db.getDefaultOrganization(), userDto);
+      indexer.index(userDto.getLogin());
+    });
+    request.setQuery("_9");
+
+    SearchMembersWsResponse result = call();
+
+    assertThat(result.getUsersList()).extracting(User::getLogin).containsExactly("L9");
+  }
+
+  @Test
+  public void json_example() {
+    UserDto ada = db.users().insertUser(u -> u.setLogin("ada.lovelace").setName("Ada Lovelace").setEmail("ada@lovelace.com"));
+    indexer.index(ada.getLogin());
+    UserDto grace = db.users().insertUser(u -> u.setLogin("grace.hopper").setName("Grace Hopper").setEmail("grace@hopper.com"));
+    indexer.index(grace.getLogin());
+    db.organizations().addMember(db.getDefaultOrganization(), ada);
+    db.organizations().addMember(db.getDefaultOrganization(), grace);
+
+    String result = ws.newRequest().execute().getInput();
+
+    assertJson(result).isSimilarTo(ws.getDef().responseExampleAsString());
+  }
+
+  @Test
+  public void fail_if_organization_is_unknown() {
+    request.setOrganization("ORGA 42");
+
+    expectedException.expect(NotFoundException.class);
+    expectedException.expectMessage("No organization with key 'ORGA 42'");
+
+    call();
+  }
+
+  @Test
+  public void fail_if_page_size_greater_than_500() {
+    request.setPageSize(501);
+
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Page size must lower than or equal to 500");
+
+    call();
+  }
+
+  @Test
+  public void fail_if_query_length_lower_than_2() {
+    request.setQuery("a");
+
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Query length must be greater than or equal to 2");
+
+    call();
+  }
 
   @Test
   public void definition() {
     WebService.Action action = ws.getDef();
 
     assertThat(action.key()).isEqualTo("search_members");
-    assertThat(action.params()).extracting(WebService.Param::key)
-      .containsOnly("q", "selected", "p", "ps", "organization");
+    assertThat(action.params()).extracting(Param::key).containsOnly("q", "selected", "p", "ps", "organization");
+    assertThat(action.description()).isNotEmpty();
     assertThat(action.responseExampleAsString()).isNotEmpty();
     assertThat(action.since()).isEqualTo("6.4");
     assertThat(action.isInternal()).isTrue();
     assertThat(action.isPost()).isFalse();
+    assertThat(action.param("organization").isInternal()).isTrue();
+
+    Param selected = action.param("selected");
+    assertThat(selected.possibleValues()).containsOnly("selected", "deselected");
+    assertThat(selected.isInternal()).isTrue();
+    assertThat(selected.defaultValue()).isEqualTo("selected");
+  }
+
+  private UserDto insertUser() {
+    UserDto userDto = db.users().insertUser();
+    indexer.index(userDto.getLogin());
+
+    return userDto;
+  }
+
+  private SearchMembersWsResponse call() {
+    TestRequest wsRequest = ws.newRequest()
+      .setMediaType(MediaTypes.PROTOBUF);
+    setNullable(request.getOrganization(), o -> wsRequest.setParam("organization", o));
+    setNullable(request.getQuery(), q -> wsRequest.setParam(Param.TEXT_QUERY, q));
+    setNullable(request.getPage(), p -> wsRequest.setParam(Param.PAGE, String.valueOf(p)));
+    setNullable(request.getPageSize(), ps -> wsRequest.setParam(Param.PAGE_SIZE, String.valueOf(ps)));
+    setNullable(request.getSelected(), s -> wsRequest.setParam(Param.SELECTED, s));
+
+    try {
+      return SearchMembersWsResponse.parseFrom(wsRequest.execute().getInputStream());
+    } catch (IOException e) {
+      throw Throwables.propagate(e);
+    }
   }
 }
index 292002049965647154b428f64b90b0d1fc029c94..b3a257f6cacce63a9a2fa2292a294fe8234176e6 100644 (file)
@@ -19,7 +19,6 @@
  */
 package org.sonar.server.user.index;
 
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
@@ -29,8 +28,11 @@ import org.junit.Test;
 import org.sonar.api.config.MapSettings;
 import org.sonar.server.es.EsTester;
 import org.sonar.server.es.SearchOptions;
+import org.sonar.server.es.SearchResult;
 
 import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.sonar.server.user.index.UserIndexDefinition.INDEX_TYPE_USER;
 
@@ -46,6 +48,8 @@ public class UserIndexTest {
 
   private UserIndex underTest;
 
+  private UserQuery.Builder userQuery = UserQuery.builder();
+
   @Before
   public void setUp() {
     underTest = new UserIndex(esTester.client());
@@ -127,16 +131,49 @@ public class UserIndexTest {
 
   @Test
   public void searchUsers() throws Exception {
-    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER1_LOGIN, Arrays.asList("user_1", "u1")).setEmail("email1"));
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER1_LOGIN, asList("user_1", "u1")).setEmail("email1"));
     esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER2_LOGIN, Collections.<String>emptyList()).setEmail("email2"));
 
-    assertThat(underTest.search(null, new SearchOptions()).getDocs()).hasSize(2);
-    assertThat(underTest.search("user", new SearchOptions()).getDocs()).hasSize(2);
-    assertThat(underTest.search("ser", new SearchOptions()).getDocs()).hasSize(2);
-    assertThat(underTest.search(USER1_LOGIN, new SearchOptions()).getDocs()).hasSize(1);
-    assertThat(underTest.search(USER2_LOGIN, new SearchOptions()).getDocs()).hasSize(1);
-    assertThat(underTest.search("mail", new SearchOptions()).getDocs()).hasSize(2);
-    assertThat(underTest.search("EMAIL1", new SearchOptions()).getDocs()).hasSize(1);
+    assertThat(underTest.search(userQuery.build(), new SearchOptions()).getDocs()).hasSize(2);
+    assertThat(underTest.search(userQuery.setTextQuery("user").build(), new SearchOptions()).getDocs()).hasSize(2);
+    assertThat(underTest.search(userQuery.setTextQuery("ser").build(), new SearchOptions()).getDocs()).hasSize(2);
+    assertThat(underTest.search(userQuery.setTextQuery(USER1_LOGIN).build(), new SearchOptions()).getDocs()).hasSize(1);
+    assertThat(underTest.search(userQuery.setTextQuery(USER2_LOGIN).build(), new SearchOptions()).getDocs()).hasSize(1);
+    assertThat(underTest.search(userQuery.setTextQuery("mail").build(), new SearchOptions()).getDocs()).hasSize(2);
+    assertThat(underTest.search(userQuery.setTextQuery("EMAIL1").build(), new SearchOptions()).getDocs()).hasSize(1);
+  }
+
+  @Test
+  public void search_users_filter_logins() throws Exception {
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER1_LOGIN, asList("user_1", "u1")).setEmail("email1"));
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER2_LOGIN, emptyList()).setEmail("email2"));
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser("user3", emptyList()).setEmail("email2"));
+
+    SearchResult<UserDoc> result = underTest.search(userQuery.setLogins(asList(USER1_LOGIN, USER2_LOGIN)).build(), new SearchOptions());
+    assertThat(result.getDocs()).hasSize(2)
+      .extracting(UserDoc::login)
+      .containsOnly(USER1_LOGIN, USER2_LOGIN);
+  }
+
+  @Test
+  public void search_users_filter_logins_that_match_exactly() {
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser("USER_1", asList("user_1", "u1")).setEmail("email1"));
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser("UsEr_1", emptyList()).setEmail("email2"));
+
+    assertThat(underTest.search(userQuery.setLogins(singletonList("USER_1")).build(), new SearchOptions()).getDocs()).hasSize(1);
+    assertThat(underTest.search(userQuery.setLogins(singletonList("USER_")).build(), new SearchOptions()).getDocs()).isEmpty();
+    assertThat(underTest.search(userQuery.setLogins(emptyList()).build(), new SearchOptions()).getDocs()).isEmpty();
+  }
+
+  @Test
+  public void search_users_exclude_logins() throws Exception {
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER1_LOGIN, asList("user_1", "u1")).setEmail("email1"));
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER2_LOGIN, emptyList()).setEmail("email2"));
+    esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser("user3", emptyList()).setEmail("email2"));
+
+    assertThat(underTest.search(userQuery.setExcludedLogins(emptyList()).build(), new SearchOptions()).getDocs()).hasSize(3);
+    assertThat(underTest.search(userQuery.setExcludedLogins(asList(USER1_LOGIN, USER2_LOGIN, "user3")).build(), new SearchOptions()).getDocs()).isEmpty();
+    assertThat(underTest.search(userQuery.setExcludedLogins(asList(USER1_LOGIN, USER2_LOGIN)).build(), new SearchOptions()).getDocs()).hasSize(1);
   }
 
   private static UserDoc newUser(String login, List<String> scmAccounts) {
diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/client/organization/SearchMembersWsRequest.java b/sonar-ws/src/main/java/org/sonarqube/ws/client/organization/SearchMembersWsRequest.java
new file mode 100644 (file)
index 0000000..06e8376
--- /dev/null
@@ -0,0 +1,82 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+package org.sonarqube.ws.client.organization;
+
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+
+public class SearchMembersWsRequest {
+  private String organization;
+  private String selected;
+  private String query;
+  private Integer page;
+  private Integer pageSize;
+
+  @CheckForNull
+  public String getOrganization() {
+    return organization;
+  }
+
+  public SearchMembersWsRequest setOrganization(@Nullable String organization) {
+    this.organization = organization;
+    return this;
+  }
+
+  @CheckForNull
+  public String getSelected() {
+    return selected;
+  }
+
+  public SearchMembersWsRequest setSelected(@Nullable String selected) {
+    this.selected = selected;
+    return this;
+  }
+
+  @CheckForNull
+  public String getQuery() {
+    return query;
+  }
+
+  public SearchMembersWsRequest setQuery(@Nullable String query) {
+    this.query = query;
+    return this;
+  }
+
+  @CheckForNull
+  public Integer getPage() {
+    return page;
+  }
+
+  public SearchMembersWsRequest setPage(@Nullable Integer page) {
+    this.page = page;
+    return this;
+  }
+
+  @CheckForNull
+  public Integer getPageSize() {
+    return pageSize;
+  }
+
+  public SearchMembersWsRequest setPageSize(@Nullable Integer pageSize) {
+    this.pageSize = pageSize;
+    return this;
+  }
+}
index fa0251a9549935b23978d8b06c371df7df562008..bb00aee3581d6aa367ae9d62e29aabe85eb61df9 100644 (file)
@@ -20,6 +20,8 @@ syntax = "proto2";
 
 package sonarqube.ws.organizations;
 
+import "ws-commons.proto";
+
 option java_package = "org.sonarqube.ws";
 option java_outer_classname = "Organizations";
 option optimize_for = SPEED;
@@ -39,6 +41,12 @@ message SearchWsResponse {
   repeated Organization organizations = 1;
 }
 
+// WS api/organizations/search_members
+message SearchMembersWsResponse {
+  optional sonarqube.ws.commons.Paging paging = 1;
+  repeated User users = 2;
+}
+
 message Organization {
   optional string key = 1;
   optional string name = 2;
@@ -47,3 +55,10 @@ message Organization {
   optional string avatar = 5;
   optional bool guarded = 6;
 }
+
+message User {
+  optional string login = 1;
+  optional string name = 2;
+  optional string avatar = 3;
+  optional int32 groupCount = 4;
+}