Move account suggestion to REST API

Accounts can now be found for internal groups and search operators
using GET /accounts/?q=... to locate candidate users that are visible
to the client.

Change-Id: Id1868ca654518a978fcb9783169f33072d3ecb15
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index f5c113f..11e4831 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -7,6 +7,44 @@
 [[account-endpoints]]
 == Account Endpoints
 
+[[suggest-account]]
+=== Suggest Account
+--
+'GET /accounts/'
+--
+
+Suggest users for a given query `q` and result limit `n`. If result
+limit is not passed, then the default 10 is used. Returns a list of
+matching link:#account-info[AccountInfo] entities.
+
+.Request
+----
+  GET /accounts/?q=John HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  [
+    {
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com",
+      "username": "john"
+    },
+    {
+      "_account_id": 1001439,
+      "name": "John Smith",
+      "email": "john.smith@example.com",
+      "username": "jsmith"
+    },
+  ]
+----
+
 [[get-account]]
 === Get Account
 --
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SuggestService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SuggestService.java
index d05dfc2..1d252fd 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SuggestService.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SuggestService.java
@@ -25,27 +25,10 @@
 
 @RpcImpl(version = Version.V2_0)
 public interface SuggestService extends RemoteJsonService {
-  void suggestAccount(String query, Boolean enabled, int limit,
-      AsyncCallback<List<AccountInfo>> callback);
-
-  /**
-   * @see #suggestAccountGroupForProject(com.google.gerrit.reviewdb.client.Project.NameKey, String, int, AsyncCallback)
-   */
-  @Deprecated
-  void suggestAccountGroup(String query, int limit,
-      AsyncCallback<List<GroupReference>> callback);
-
   void suggestAccountGroupForProject(Project.NameKey project, String query,
       int limit, AsyncCallback<List<GroupReference>> callback);
 
   /**
-   * @see #suggestChangeReviewer(com.google.gerrit.reviewdb.client.Change.Id, String, int, AsyncCallback)
-   */
-  @Deprecated
-  void suggestReviewer(Project.NameKey project, String query, int limit,
-      AsyncCallback<List<ReviewerInfo>> callback);
-
-  /**
    * Suggests reviewers. A reviewer can be a user or a group. Inactive users,
    * the system groups {@code SystemGroupBackend#ANONYMOUS_USERS} and
    * {@code SystemGroupBackend#REGISTERED_USERS} and groups that have more than
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
index 8906da4..59d65f6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
@@ -33,6 +33,15 @@
     return new RestApi("/accounts/").view("self");
   }
 
+  public static void suggest(String query, int limit,
+      AsyncCallback<JsArray<AccountInfo>> cb) {
+    new RestApi("/accounts/")
+      .addParameter("q", query)
+      .addParameter("n", limit)
+      .background()
+      .get(cb);
+  }
+
   public static void putDiffPreferences(DiffPreferences in,
       AsyncCallback<DiffPreferences> cb) {
     self().view("preferences.diff").put(in, cb);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
index 0562689..cf3e940 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
 import com.google.gerrit.client.ui.AccountLinkPanel;
+import com.google.gerrit.client.ui.AccountSuggestOracle;
 import com.google.gerrit.client.ui.AddMemberBox;
 import com.google.gerrit.client.ui.FancyFlexTable;
 import com.google.gerrit.client.ui.Hyperlink;
@@ -80,7 +81,10 @@
 
 
   private void initMemberList() {
-    addMemberBox = new AddMemberBox();
+    addMemberBox = new AddMemberBox(
+        Util.C.buttonAddGroupMember(),
+        Util.C.defaultAccountName(),
+        new AccountSuggestOracle());
 
     addMemberBox.addClickHandler(new ClickHandler() {
       @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
index ac73dd2..78350db 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.client.ui;
 
 import com.google.gerrit.client.FormatUtil;
-import com.google.gerrit.client.RpcStatus;
+import com.google.gerrit.client.account.AccountApi;
+import com.google.gerrit.client.account.AccountInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.data.AccountInfo;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.ui.SuggestOracle;
 
 import java.util.ArrayList;
@@ -26,25 +28,18 @@
 /** Suggestion Oracle for Account entities. */
 public class AccountSuggestOracle extends SuggestAfterTypingNCharsOracle {
   @Override
-  public void _onRequestSuggestions(final Request req, final Callback callback) {
-    RpcStatus.hide(new Runnable() {
-      @Override
-      public void run() {
-        SuggestUtil.SVC.suggestAccount(req.getQuery(), Boolean.TRUE,
-            req.getLimit(),
-            new GerritCallback<List<AccountInfo>>() {
-              @Override
-              public void onSuccess(final List<AccountInfo> result) {
-                final ArrayList<AccountSuggestion> r =
-                    new ArrayList<>(result.size());
-                for (final AccountInfo p : result) {
-                  r.add(new AccountSuggestion(p));
-                }
-                callback.onSuggestionsReady(req, new Response(r));
-              }
-            });
-      }
-    });
+  public void _onRequestSuggestions(final Request req, final Callback cb) {
+    AccountApi.suggest(req.getQuery(), req.getLimit(),
+        new GerritCallback<JsArray<AccountInfo>>() {
+          @Override
+          public void onSuccess(JsArray<AccountInfo> in) {
+            List<AccountSuggestion> r = new ArrayList<>(in.length());
+            for (AccountInfo p : Natives.asList(in)) {
+              r.add(new AccountSuggestion(p));
+            }
+            cb.onSuggestionsReady(req, new Response(r));
+          }
+        });
   }
 
   private static class AccountSuggestion implements SuggestOracle.Suggestion {
@@ -56,12 +51,12 @@
 
     @Override
     public String getDisplayString() {
-      return FormatUtil.nameEmail(FormatUtil.asInfo(info));
+      return FormatUtil.nameEmail(info);
     }
 
     @Override
     public String getReplacementString() {
-      return FormatUtil.nameEmail(FormatUtil.asInfo(info));
+      return FormatUtil.nameEmail(info);
     }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
index d4aaa4c..80d4a7b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.ui;
 
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.admin.Util;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
@@ -38,11 +37,6 @@
   private final SuggestBox nameTxt;
   private boolean submitOnSelection;
 
-  public AddMemberBox() {
-    this(Util.C.buttonAddGroupMember(), Util.C.defaultAccountName(),
-        new AccountSuggestOracle());
-  }
-
   public AddMemberBox(final String buttonLabel, final String hint,
       final SuggestOracle suggestOracle) {
     addPanel = new FlowPanel();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SuggestServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SuggestServiceImpl.java
index ff64c3c..7de6f4e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SuggestServiceImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SuggestServiceImpl.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountVisibility;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
@@ -63,7 +62,6 @@
   private final AccountCache accountCache;
   private final GroupMembers.Factory groupMembersFactory;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
-  private final AccountControl.Factory accountControlFactory;
   private final ChangeControl.Factory changeControlFactory;
   private final ProjectControl.Factory projectControlFactory;
   private final Config cfg;
@@ -76,7 +74,6 @@
       final GroupMembers.Factory groupMembersFactory,
       final Provider<CurrentUser> currentUser,
       final IdentifiedUser.GenericFactory identifiedUserFactory,
-      final AccountControl.Factory accountControlFactory,
       final ChangeControl.Factory changeControlFactory,
       final ProjectControl.Factory projectControlFactory,
       @GerritServerConfig final Config cfg, final GroupBackend groupBackend) {
@@ -85,7 +82,6 @@
     this.accountCache = accountCache;
     this.groupMembersFactory = groupMembersFactory;
     this.identifiedUserFactory = identifiedUserFactory;
-    this.accountControlFactory = accountControlFactory;
     this.changeControlFactory = changeControlFactory;
     this.projectControlFactory = projectControlFactory;
     this.cfg = cfg;
@@ -110,22 +106,6 @@
     boolean isVisible(Account account) throws OrmException;
   }
 
-  @Override
-  public void suggestAccount(final String query, final Boolean active,
-      final int limit, final AsyncCallback<List<AccountInfo>> callback) {
-    run(callback, new Action<List<AccountInfo>>() {
-      @Override
-      public List<AccountInfo> run(final ReviewDb db) throws OrmException {
-        return suggestAccount(db, query, active, limit, new VisibilityControl() {
-          @Override
-          public boolean isVisible(Account account) throws OrmException {
-            return accountControlFactory.get().canSee(account);
-          }
-        });
-      }
-    });
-  }
-
   private List<AccountInfo> suggestAccount(final ReviewDb db,
       final String query, final Boolean active, final int limit,
       VisibilityControl visibilityControl)
@@ -178,12 +158,6 @@
   }
 
   @Override
-  public void suggestAccountGroup(final String query, final int limit,
-      final AsyncCallback<List<GroupReference>> callback) {
-    suggestAccountGroupForProject(null, query, limit, callback);
-  }
-
-  @Override
   public void suggestAccountGroupForProject(final Project.NameKey project,
       final String query, final int limit,
       final AsyncCallback<List<GroupReference>> callback) {
@@ -211,13 +185,6 @@
   }
 
   @Override
-  public void suggestReviewer(Project.NameKey project, String query, int limit,
-      AsyncCallback<List<ReviewerInfo>> callback) {
-    // The RPC is deprecated, but return an empty list for RPC API compatibility.
-    callback.onSuccess(Collections.<ReviewerInfo>emptyList());
-  }
-
-  @Override
   public void suggestChangeReviewer(final Change.Id change,
       final String query, final int limit,
       final AsyncCallback<List<ReviewerInfo>> callback) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
index 5ef745b..efe7322 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
@@ -40,6 +40,7 @@
   private final AccountResolver resolver;
   private final AccountControl.Factory accountControlFactory;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final Provider<SuggestAccounts> list;
   private final DynamicMap<RestView<AccountResource>> views;
   private final CreateAccount.Factory createAccountFactory;
 
@@ -48,12 +49,14 @@
       AccountResolver resolver,
       AccountControl.Factory accountControlFactory,
       IdentifiedUser.GenericFactory userFactory,
+      Provider<SuggestAccounts> list,
       DynamicMap<RestView<AccountResource>> views,
       CreateAccount.Factory createAccountFactory) {
     this.self = self;
     this.resolver = resolver;
     this.accountControlFactory = accountControlFactory;
     this.userFactory = userFactory;
+    this.list = list;
     this.views = views;
     this.createAccountFactory = createAccountFactory;
   }
@@ -128,7 +131,7 @@
 
   @Override
   public RestView<TopLevelResource> list() throws ResourceNotFoundException {
-    throw new ResourceNotFoundException();
+    return list.get();
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java
new file mode 100644
index 0000000..07936d9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SuggestAccounts.java
@@ -0,0 +1,156 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+class SuggestAccounts implements RestReadView<TopLevelResource> {
+  private static final int MAX_RESULTS = 100;
+  private static final String MAX_SUFFIX = "\u9fa5";
+
+  private final AccountControl accountControl;
+  private final AccountLoader accountLoader;
+  private final ReviewDb db;
+  private final boolean suggest;
+  private final int suggestFrom;
+
+  private int limit = 10;
+
+  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of users to return")
+  void setLimit(int n) {
+    if (n < 0) {
+      limit = 10;
+    } else if (n == 0) {
+      limit = MAX_RESULTS;
+    } else {
+      limit = Math.min(n, MAX_RESULTS);
+    }
+  }
+
+  @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", usage = "match users")
+  private String query;
+
+  @Inject
+  SuggestAccounts(AccountControl.Factory accountControlFactory,
+      AccountLoader.Factory accountLoaderFactory,
+      ReviewDb db,
+      @GerritServerConfig Config cfg) {
+    accountControl = accountControlFactory.get();
+    accountLoader = accountLoaderFactory.create(true);
+    this.db = db;
+    this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
+
+    if ("off".equalsIgnoreCase(cfg.getString("suggest", null, "accounts"))) {
+      suggest = false;
+    } else {
+      boolean suggest;
+      try {
+        AccountVisibility av =
+            cfg.getEnum("suggest", null, "accounts", AccountVisibility.ALL);
+        suggest = (av != AccountVisibility.NONE);
+      } catch (IllegalArgumentException err) {
+        suggest = cfg.getBoolean("suggest", null, "accounts", true);
+      }
+      this.suggest = suggest;
+    }
+  }
+
+  @Override
+  public List<AccountInfo> apply(TopLevelResource rsrc)
+      throws OrmException, BadRequestException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new BadRequestException("missing query field");
+    }
+
+    if (!suggest || query.length() < suggestFrom) {
+      return Collections.emptyList();
+    }
+
+    String a = query;
+    String b = a + MAX_SUFFIX;
+
+    Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
+    Map<Account.Id, String> queryEmail = new HashMap<>();
+
+    for (Account p : db.accounts().suggestByFullName(a, b, limit)) {
+      addSuggestion(matches, p.getId());
+    }
+    if (matches.size() < limit) {
+      for (Account p : db.accounts()
+          .suggestByPreferredEmail(a, b, limit - matches.size())) {
+        addSuggestion(matches, p.getId());
+      }
+    }
+    if (matches.size() < limit) {
+      for (AccountExternalId e : db.accountExternalIds()
+          .suggestByEmailAddress(a, b, limit - matches.size())) {
+        if (addSuggestion(matches, e.getAccountId())) {
+          queryEmail.put(e.getAccountId(), e.getEmailAddress());
+        }
+      }
+    }
+
+    accountLoader.fill();
+    for (Map.Entry<Account.Id, String> p : queryEmail.entrySet()) {
+      AccountInfo info = matches.get(p.getKey());
+      if (info != null) {
+        info.email = p.getValue();
+      }
+    }
+
+    List<AccountInfo> m = new ArrayList<>(matches.values());
+    Collections.sort(m, new Comparator<AccountInfo>() {
+      @Override
+      public int compare(AccountInfo a, AccountInfo b) {
+        return ComparisonChain.start()
+          .compare(a.name, b.name, Ordering.natural().nullsLast())
+          .compare(a.email, b.email, Ordering.natural().nullsLast())
+          .result();
+      }
+    });
+    return m;
+  }
+
+  private boolean addSuggestion(Map<Account.Id, AccountInfo> map, Account.Id id) {
+    if (!map.containsKey(id) && accountControl.canSee(id)) {
+      map.put(id, accountLoader.get(id));
+      return true;
+    }
+    return false;
+  }
+}