Add REST Interface to list and replenish limits

Implement a REST API that is the equivalent of the ssh "list" and
"replenish" commands detailed in the existing rate-limiter plugin
documentation. To mimic the functionality of the ssh command, keywords
are used in the endpoints and options are provided as parameters.

Feature: Issue 10304
Change-Id: I16753b26b994109b25134605f577fc688bb46060
diff --git a/BUILD b/BUILD
index 354abff..b3af8df 100644
--- a/BUILD
+++ b/BUILD
@@ -14,6 +14,7 @@
         "Gerrit-PluginName: rate-limiter",
         "Gerrit-Module: com.googlesource.gerrit.plugins.ratelimiter.Module",
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.ratelimiter.SshModule",
+        "Gerrit-HttpModule: com.googlesource.gerrit.plugins.ratelimiter.HttpModule",
     ],
     resources = glob(["src/main/resources/**/*"]),
 )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/HttpModule.java
new file mode 100644
index 0000000..c466541
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/HttpModule.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.ratelimiter;
+
+import com.google.inject.servlet.ServletModule;
+
+class HttpModule extends ServletModule {
+  @Override
+  protected void configureServlets() {
+    serve("/list", "/replenish").with(RateLimiterServlet.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ListCommand.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ListCommand.java
index 30442e9..1b2547f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ListCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ListCommand.java
@@ -16,19 +16,12 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 import static com.googlesource.gerrit.plugins.ratelimiter.Module.UPLOAD_PACK_PER_HOUR;
 
-import com.google.common.cache.LoadingCache;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import com.google.inject.name.Named;
-import java.time.Duration;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
 
 @AdminHighPriorityCommand
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
@@ -37,18 +30,15 @@
     description = "Display rate limits statistics",
     runsAt = MASTER_OR_SLAVE)
 final class ListCommand extends SshCommand {
-  private static final String FORMAT = "%-26s %-17s %-19s %-15s %s";
   private static final String DASHED_LINE =
       "---------------------------------------------------------------------------------------------";
-  private final LoadingCache<String, RateLimiter> uploadPackPerHour;
-  private final UserResolver userResolver;
+  private final RateLimiterProcessing rateLimiterProcessing;
+
+  static final String FORMAT = "%-26s %-17s %-19s %-15s %s";
 
   @Inject
-  ListCommand(
-      @Named(UPLOAD_PACK_PER_HOUR) LoadingCache<String, RateLimiter> uploadPackPerHour,
-      UserResolver userResolver) {
-    this.uploadPackPerHour = uploadPackPerHour;
-    this.userResolver = userResolver;
+  ListCommand(RateLimiterProcessing rateLimiterProcessing) {
+    this.rateLimiterProcessing = rateLimiterProcessing;
   }
 
   @Override
@@ -66,32 +56,10 @@
               "Used Permits",
               "Replenish in"));
       stdout.println(DASHED_LINE);
-      uploadPackPerHour.asMap().entrySet().stream()
-          .sorted(Map.Entry.comparingByValue())
-          .forEach(this::printEntry);
+      stdout.println(rateLimiterProcessing.listPermits());
       stdout.println(DASHED_LINE);
     } catch (Exception e) {
       throw die(e);
     }
   }
-
-  private void printEntry(Entry<String, RateLimiter> entry) {
-    stdout.println(
-        String.format(
-            FORMAT,
-            getDisplayValue(entry.getKey()),
-            permits(entry.getValue().permitsPerHour()),
-            permits(entry.getValue().availablePermits()),
-            permits(entry.getValue().usedPermits()),
-            Duration.ofSeconds(entry.getValue().remainingTime(TimeUnit.SECONDS))));
-  }
-
-  private String permits(int value) {
-    return value == Integer.MAX_VALUE ? "unlimited" : Integer.toString(value);
-  }
-
-  private String getDisplayValue(String key) {
-    Optional<String> currentUser = userResolver.getUserName(key);
-    return currentUser.map(name -> key + " (" + name + ")").orElse(key);
-  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterProcessing.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterProcessing.java
new file mode 100644
index 0000000..c8c2f60
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterProcessing.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.ratelimiter;
+
+import static com.googlesource.gerrit.plugins.ratelimiter.ListCommand.FORMAT;
+import static com.googlesource.gerrit.plugins.ratelimiter.Module.UPLOAD_PACK_PER_HOUR;
+
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gson.*;
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class RateLimiterProcessing {
+
+  private final LoadingCache<String, RateLimiter> uploadPackPerHour;
+  private final UserResolver userResolver;
+  private final AccountResolver accountResolver;
+
+  @Inject
+  public RateLimiterProcessing(
+      @Named(UPLOAD_PACK_PER_HOUR) LoadingCache<String, RateLimiter> uploadPackPerHour,
+      UserResolver userResolver,
+      AccountResolver accountResolver) {
+    this.uploadPackPerHour = uploadPackPerHour;
+    this.userResolver = userResolver;
+    this.accountResolver = accountResolver;
+  }
+
+  public String listPermits() {
+    return uploadPackPerHour.asMap().entrySet().stream()
+        .sorted(Map.Entry.comparingByValue())
+        .map(
+            entry ->
+                String.format(
+                    FORMAT,
+                    getDisplayValue(entry.getKey(), userResolver),
+                    permits(entry.getValue().permitsPerHour()),
+                    permits(entry.getValue().availablePermits()),
+                    permits(entry.getValue().usedPermits()),
+                    Duration.ofSeconds(entry.getValue().remainingTime(TimeUnit.SECONDS))))
+        .reduce("", String::concat);
+  }
+
+  public String listPermitsAsJson() {
+    List<String> permitList = new ArrayList<>();
+    Gson gson = new GsonBuilder().setPrettyPrinting().create();
+    uploadPackPerHour.asMap().entrySet().stream()
+        .sorted(Map.Entry.comparingByValue())
+        .map(this::getJsonObjectString)
+        .forEach(permitList::add);
+    return gson.toJson(JsonParser.parseString(permitList.toString()));
+  }
+
+  private String getJsonObjectString(Map.Entry<String, RateLimiter> entry) {
+    JsonObject jsonObject = new JsonObject();
+    jsonObject.addProperty("AccountId", getDisplayValue(entry.getKey(), userResolver));
+    jsonObject.addProperty("permits_per_hour", permits(entry.getValue().permitsPerHour()));
+    jsonObject.addProperty("available_permits", permits(entry.getValue().availablePermits()));
+    jsonObject.addProperty("used_permit", permits(entry.getValue().usedPermits()));
+    jsonObject.addProperty(
+        "replenish_in",
+        Duration.ofSeconds(entry.getValue().remainingTime(TimeUnit.SECONDS)).toString());
+    return jsonObject.toString();
+  }
+
+  private String permits(int value) {
+    return value == Integer.MAX_VALUE ? "unlimited" : Integer.toString(value);
+  }
+
+  private String getDisplayValue(String key, UserResolver userResolver) {
+    Optional<String> currentUser = userResolver.getUserName(key);
+    return currentUser.map(name -> key + " (" + name + ")").orElse(key);
+  }
+
+  public void replenish(boolean all, List<Account.Id> accountIds, List<String> remoteHosts) {
+    if (all && (!accountIds.isEmpty() || !remoteHosts.isEmpty())) {
+      throw new IllegalArgumentException("cannot use --all with --user or --remotehost");
+    }
+    if (all) {
+      for (RateLimiter rateLimiter : uploadPackPerHour.asMap().values()) {
+        rateLimiter.replenishPermits();
+      }
+      return;
+    }
+    for (Account.Id accountId : accountIds) {
+      replenishIfPresent(Integer.toString(accountId.get()));
+    }
+    for (String remoteHost : remoteHosts) {
+      replenishIfPresent(remoteHost);
+    }
+    return;
+  }
+
+  List<Account.Id> convertToAccountId(String[] usernames)
+      throws ConfigInvalidException, IOException, ResourceNotFoundException {
+    List<Account.Id> accountIds = new ArrayList<>();
+    for (String user : usernames) {
+      AccountResolver.Result accountId = accountResolver.resolve(user);
+      if (accountId.asIdSet().isEmpty())
+        throw new ResourceNotFoundException(String.format("User %s not found", user));
+      accountIds.addAll(accountId.asIdSet());
+    }
+    return accountIds;
+  }
+
+  private void replenishIfPresent(String key) {
+    RateLimiter limiter = uploadPackPerHour.getIfPresent(key);
+    if (limiter != null) {
+      limiter.replenishPermits();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterServlet.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterServlet.java
new file mode 100644
index 0000000..2de7975
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterServlet.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.ratelimiter;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.annotations.CapabilityScope;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.*;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@RequiresCapability(value = GlobalCapability.ADMINISTRATE_SERVER, scope = CapabilityScope.CORE)
+@Singleton
+public class RateLimiterServlet extends HttpServlet {
+  private final RateLimiterProcessing rateLimiterProcessing;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  RateLimiterServlet(
+      RateLimiterProcessing rateLimiterProcessing, PermissionBackend permissionBackend) {
+    this.rateLimiterProcessing = rateLimiterProcessing;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    try {
+      permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (AuthException | PermissionBackendException e) {
+      setResponse(res, HttpServletResponse.SC_FORBIDDEN, "Authentication failed");
+      return;
+    }
+    if ("/list".equals(req.getPathInfo())) {
+      setResponse(res, HttpServletResponse.SC_OK, rateLimiterProcessing.listPermitsAsJson());
+    }
+  }
+
+  @Override
+  protected void doPost(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    try {
+      permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (AuthException | PermissionBackendException e) {
+      setResponse(res, HttpServletResponse.SC_FORBIDDEN, "Authentication failed");
+      return;
+    }
+    if ("/replenish".equals(req.getPathInfo())) {
+      try {
+        String[] replenishForUsers = new String[0];
+        List<String> replenishForHosts = Collections.EMPTY_LIST;
+        boolean replenishForAll = false;
+        if (req.getParameter("user") != null) {
+          replenishForUsers = req.getParameterValues("user");
+        }
+        if (req.getParameter("remotehost") != null) {
+          replenishForHosts = Arrays.asList(req.getParameterValues("host"));
+        }
+        if (req.getParameter("all") != null) {
+          replenishForAll = "true".equals(req.getParameter("all"));
+        }
+
+        List<Account.Id> accountIds = rateLimiterProcessing.convertToAccountId(replenishForUsers);
+        rateLimiterProcessing.replenish(replenishForAll, accountIds, replenishForHosts);
+        setResponse(res, HttpServletResponse.SC_NO_CONTENT, accountIds.toString());
+      } catch (ResourceNotFoundException | ConfigInvalidException | IllegalArgumentException e) {
+        setResponse(res, HttpServletResponse.SC_FORBIDDEN, "Fatal: " + e.getMessage());
+      }
+    }
+  }
+
+  private void setResponse(HttpServletResponse httpResponse, int statusCode, String value)
+      throws IOException {
+    httpResponse.setContentType("application/json");
+    httpResponse.setStatus(statusCode);
+    PrintWriter writer = httpResponse.getWriter();
+    writer.print(value);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ReplenishCommand.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ReplenishCommand.java
index a4646d5..6e589f6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ReplenishCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ReplenishCommand.java
@@ -50,36 +50,22 @@
   private List<String> remoteHosts = new ArrayList<>();
 
   private final LoadingCache<String, RateLimiter> uploadPackPerHour;
+  private final RateLimiterProcessing rateLimiterProcessing;
 
   @Inject
   ReplenishCommand(
-      @Named(UPLOAD_PACK_PER_HOUR) LoadingCache<String, RateLimiter> uploadPackPerHour) {
+      @Named(UPLOAD_PACK_PER_HOUR) LoadingCache<String, RateLimiter> uploadPackPerHour,
+      RateLimiterProcessing rateLimiterProcessing) {
     this.uploadPackPerHour = uploadPackPerHour;
+    this.rateLimiterProcessing = rateLimiterProcessing;
   }
 
   @Override
   protected void run() throws UnloggedFailure {
-    if (all && (!accountIds.isEmpty() || !remoteHosts.isEmpty())) {
-      throw die("cannot use --all with --user or --remotehost");
-    }
-    if (all) {
-      for (RateLimiter rateLimiter : uploadPackPerHour.asMap().values()) {
-        rateLimiter.replenishPermits();
-      }
-      return;
-    }
-    for (Account.Id accountId : accountIds) {
-      replenishIfPresent(Integer.toString(accountId.get()));
-    }
-    for (String remoteHost : remoteHosts) {
-      replenishIfPresent(remoteHost);
-    }
-  }
-
-  private void replenishIfPresent(String key) {
-    RateLimiter limiter = uploadPackPerHour.getIfPresent(key);
-    if (limiter != null) {
-      limiter.replenishPermits();
+    try {
+      rateLimiterProcessing.replenish(all, accountIds, remoteHosts);
+    } catch (IllegalArgumentException e) {
+      throw die(e.getMessage());
     }
   }
 }
diff --git a/src/main/resources/Documentation/rest-api-rate-limiter.md b/src/main/resources/Documentation/rest-api-rate-limiter.md
new file mode 100644
index 0000000..b3fbb0e
--- /dev/null
+++ b/src/main/resources/Documentation/rest-api-rate-limiter.md
@@ -0,0 +1,76 @@
+@PLUGIN@ - /replication/ REST API
+===================================
+This page describes the REST endpoint that is added by the @PLUGIN@
+plugin.
+Please also take note of the general information on the
+[REST API](../../../Documentation/rest-api.html).
+This API implements a REST equivalent of the Ssh rename-project command.
+For more information, refer to:
+* [Ssh list command](cmd-list.md)
+* [Ssh replenish command](cmd-replenish.md)
+------------------------------------------
+REQUEST
+-------
+```
+GET /plugins/rate-limiter/list HTTP/1.0
+```
+To get list of rate limit statistics.
+
+RESPONSE
+--------
+```
+[
+  {
+    "AccountId": "1000000 (admin)",
+    "permits_per_hour": "unlimited",
+    "available_permits": "unlimited",
+    "used_permit": "0",
+    "replenish_in": "PT0S"
+  },
+  {
+    "AccountId": "1000001 (testUser)",
+    "permits_per_hour": "unlimited",
+    "available_permits": "unlimited",
+    "used_permit": "0",
+    "replenish_in": "PT0S"
+  }
+]
+```
+
+REQUEST
+-------
+```
+POST /plugins/rate-limiter/repenish HTTP/1.0
+```
+
+Replenish permits for a given remotehost/user, or all. Uses parameters to specify
+which permit to replenish.
+
+To replenish all permits for user ```admin``` use:
+
+```
+POST /plugins/rate-limiter/replenish?user=ttyt&user=admin HTTP/1.0
+```
+
+To replenish all permits for remotehost ```127.0.0.0``` use:
+
+```
+POST /plugins/rate-limiter/replenish?user=ttyt&host=127.0.0 HTTP/1.0
+```
+
+To replenish all permits use:
+```
+POST /plugins/rate-limiter/replenish?user=ttyt&all=true HTTP/1.0
+```
+
+RESPONSE
+--------
+```
+HTTP/1.1 204 NO_CONTENT
+```
+
+ACCESS
+------
+Same as ssh version of the command caller must be a member of a group that is granted
+the 'Administrate Server' capability.
+