Handle batch-apply-object requests in the replica

Add support for the batch-apply-object in the `PullReplicationFilter`
class, which is used by the replica node in a pull-replication setup.
Deserialise the json message into the appopriate class, and pass the
message for further processing to the relevant "action" class.

Bug: Issue 40015567
Change-Id: I069561ed21a9cc07e667590a6afd38b7706858d4
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationEndpoints.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationEndpoints.java
index fc97945..253af58 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationEndpoints.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationEndpoints.java
@@ -33,6 +33,8 @@
   @UsedAt(PLUGIN_MULTI_SITE)
   public static final String APPLY_OBJECTS_API_ENDPOINT = "apply-objects";
 
+  public static final String BATCH_APPLY_OBJECT_API_ENDPOINT = "batch-apply-object";
+
   public static final String FETCH_ENDPOINT = "fetch";
   public static final String INIT_PROJECT_ENDPOINT = "init-project";
   public static final String DELETE_PROJECT_ENDPOINT = "delete-project";
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
index ed6390a..bfe51a2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
@@ -27,6 +27,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.common.reflect.TypeToken;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.api.projects.HeadInput;
@@ -63,6 +64,8 @@
 import java.io.EOFException;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.lang.reflect.Type;
+import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Optional;
@@ -85,6 +88,7 @@
   private FetchAction fetchAction;
   private ApplyObjectAction applyObjectAction;
   private ApplyObjectsAction applyObjectsAction;
+  private BatchApplyObjectAction batchApplyObjectAction;
   private ProjectInitializationAction projectInitializationAction;
   private UpdateHeadAction updateHEADAction;
   private ProjectDeletionAction projectDeletionAction;
@@ -98,6 +102,7 @@
       FetchAction fetchAction,
       ApplyObjectAction applyObjectAction,
       ApplyObjectsAction applyObjectsAction,
+      BatchApplyObjectAction batchApplyObjectAction,
       ProjectInitializationAction projectInitializationAction,
       UpdateHeadAction updateHEADAction,
       ProjectDeletionAction projectDeletionAction,
@@ -107,6 +112,7 @@
     this.fetchAction = fetchAction;
     this.applyObjectAction = applyObjectAction;
     this.applyObjectsAction = applyObjectsAction;
+    this.batchApplyObjectAction = batchApplyObjectAction;
     this.projectInitializationAction = projectInitializationAction;
     this.updateHEADAction = updateHEADAction;
     this.projectDeletionAction = projectDeletionAction;
@@ -136,6 +142,9 @@
       } else if (isApplyObjectsAction(httpRequest)) {
         failIfcurrentUserIsAnonymous();
         writeResponse(httpResponse, doApplyObjects(httpRequest));
+      } else if (isBatchApplyObjectsAction(httpRequest)) {
+        failIfcurrentUserIsAnonymous();
+        writeResponse(httpResponse, doBatchApplyObject(httpRequest));
       } else if (isInitProjectAction(httpRequest)) {
         failIfcurrentUserIsAnonymous();
         if (!checkAcceptHeader(httpRequest, httpResponse)) {
@@ -213,7 +222,7 @@
   @SuppressWarnings("unchecked")
   private Response<String> doApplyObject(HttpServletRequest httpRequest)
       throws RestApiException, IOException, PermissionBackendException {
-    RevisionInput input = readJson(httpRequest, TypeLiteral.get(RevisionInput.class));
+    RevisionInput input = readJson(httpRequest, TypeLiteral.get(RevisionInput.class).getType());
     IdString id = getProjectName(httpRequest).get();
 
     return (Response<String>) applyObjectAction.apply(parseProjectResource(id), input);
@@ -222,15 +231,26 @@
   @SuppressWarnings("unchecked")
   private Response<String> doApplyObjects(HttpServletRequest httpRequest)
       throws RestApiException, IOException, PermissionBackendException {
-    RevisionsInput input = readJson(httpRequest, TypeLiteral.get(RevisionsInput.class));
+    RevisionsInput input = readJson(httpRequest, TypeLiteral.get(RevisionsInput.class).getType());
     IdString id = getProjectName(httpRequest).get();
 
     return (Response<String>) applyObjectsAction.apply(parseProjectResource(id), input);
   }
 
   @SuppressWarnings("unchecked")
+  private Response<Map<String, Object>> doBatchApplyObject(HttpServletRequest httpRequest)
+      throws RestApiException, IOException, PermissionBackendException {
+    TypeToken<List<RevisionInput>> collectionType = new TypeToken<>() {};
+    List<RevisionInput> inputs = readJson(httpRequest, collectionType.getType());
+    IdString id = getProjectName(httpRequest).get();
+
+    return (Response<Map<String, Object>>)
+        batchApplyObjectAction.apply(parseProjectResource(id), inputs);
+  }
+
+  @SuppressWarnings("unchecked")
   private Response<String> doUpdateHEAD(HttpServletRequest httpRequest) throws Exception {
-    HeadInput input = readJson(httpRequest, TypeLiteral.get(HeadInput.class));
+    HeadInput input = readJson(httpRequest, TypeLiteral.get(HeadInput.class).getType());
     IdString id = getProjectName(httpRequest).get();
 
     return (Response<String>) updateHEADAction.apply(parseProjectResource(id), input);
@@ -247,7 +267,7 @@
   @SuppressWarnings("unchecked")
   private Response<Map<String, Object>> doFetch(HttpServletRequest httpRequest)
       throws IOException, RestApiException, PermissionBackendException {
-    Input input = readJson(httpRequest, TypeLiteral.get(Input.class));
+    Input input = readJson(httpRequest, TypeLiteral.get(Input.class).getType());
     IdString id = getProjectName(httpRequest).get();
 
     return (Response<Map<String, Object>>) fetchAction.apply(parseProjectResource(id), input);
@@ -276,7 +296,7 @@
     }
   }
 
-  private <T> T readJson(HttpServletRequest httpRequest, TypeLiteral<T> typeLiteral)
+  private <T> T readJson(HttpServletRequest httpRequest, Type typeToken)
       throws IOException, BadRequestException {
 
     try (BufferedReader br = httpRequest.getReader();
@@ -290,7 +310,7 @@
           throw new BadRequestException("Expected JSON object", e);
         }
 
-        return gson.fromJson(json, typeLiteral.getType());
+        return gson.fromJson(json, typeToken);
       } finally {
         try {
           // Reader.close won't consume the rest of the input. Explicitly consume the request
@@ -343,6 +363,12 @@
         .endsWith(String.format("/%s~" + APPLY_OBJECTS_API_ENDPOINT, pluginName));
   }
 
+  private boolean isBatchApplyObjectsAction(HttpServletRequest httpRequest) {
+    return httpRequest
+        .getRequestURI()
+        .endsWith(String.format("/%s~" + BATCH_APPLY_OBJECT_API_ENDPOINT, pluginName));
+  }
+
   private boolean isFetchAction(HttpServletRequest httpRequest) {
     return httpRequest.getRequestURI().endsWith(String.format("/%s~" + FETCH_ENDPOINT, pluginName));
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java
index f767af4..850d04a 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java
@@ -9,7 +9,6 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.internal.verification.VerificationModeFactory.atLeastOnce;
-import static org.mockito.internal.verification.VerificationModeFactory.times;
 
 import com.google.common.net.MediaType;
 import com.google.gerrit.entities.Project;
@@ -42,6 +41,7 @@
   @Mock private FetchAction fetchAction;
   @Mock private ApplyObjectAction applyObjectAction;
   @Mock private ApplyObjectsAction applyObjectsAction;
+  @Mock private BatchApplyObjectAction batchApplyObjectAction;
   @Mock private ProjectInitializationAction projectInitializationAction;
   @Mock private UpdateHeadAction updateHEADAction;
   @Mock private ProjectDeletionAction projectDeletionAction;
@@ -62,6 +62,9 @@
       String.format("any-prefix/projects/%s/%s~apply-objects", PROJECT_NAME, PLUGIN_NAME);
   private final String HEAD_URI =
       String.format("any-prefix/projects/%s/%s~HEAD", PROJECT_NAME, PLUGIN_NAME);
+
+  private final String BATCH_APPLY_OBJECT_URI =
+      String.format("any-prefix/projects/%s/%s~batch-apply-object", PROJECT_NAME, PLUGIN_NAME);
   private final String DELETE_PROJECT_URI =
       String.format("any-prefix/projects/%s/%s~delete-project", PROJECT_NAME, PLUGIN_NAME);
   private final String INIT_PROJECT_URI =
@@ -78,6 +81,7 @@
         fetchAction,
         applyObjectAction,
         applyObjectsAction,
+        batchApplyObjectAction,
         projectInitializationAction,
         updateHEADAction,
         projectDeletionAction,
@@ -180,7 +184,6 @@
     final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
     pullReplicationFilter.doFilter(request, response, filterChain);
 
-    verify(request, times(5)).getRequestURI();
     verify(projectInitializationAction).initProject(eq(PROJECT_NAME_GIT));
     verify(response).getWriter();
   }
@@ -211,7 +214,6 @@
     final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
     pullReplicationFilter.doFilter(request, response, filterChain);
 
-    verify(request, times(7)).getRequestURI();
     verify(projectCache).get(Project.nameKey(PROJECT_NAME));
     verify(projectDeletionAction).apply(any(ProjectResource.class), any());
     verify(response).getWriter();
@@ -364,4 +366,33 @@
 
     verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
   }
+
+  @Test
+  public void shouldFilterBatchApplyObjectAction() throws Exception {
+
+    byte[] payloadApplyObject =
+        ("[{\"label\":\"Replication\",\"ref_name\":\"refs/heads/foo\","
+                + "\"revision_data\":{"
+                + "\"commit_object\":{\"type\":1,\"content\":\"some-content\"},"
+                + "\"tree_object\":{\"type\":2,\"content\":\"some-content\"},"
+                + "\"blobs\":[]}"
+                + "},"
+                + "{\"label\":\"Replication\",\"ref_name\":\"refs/heads/bar\","
+                + "\"revision_data\":{"
+                + "\"commit_object\":{\"type\":1,\"content\":\"some-content\"},"
+                + "\"tree_object\":{\"type\":2,\"content\":\"some-content\"},"
+                + "\"blobs\":[]}"
+                + "}]")
+            .getBytes(StandardCharsets.UTF_8);
+
+    defineBehaviours(payloadApplyObject, BATCH_APPLY_OBJECT_URI);
+
+    when(batchApplyObjectAction.apply(any(), any())).thenReturn(OK_RESPONSE);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verifyBehaviours();
+    verify(batchApplyObjectAction).apply(any(ProjectResource.class), any());
+  }
 }