You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by st...@apache.org on 2023/09/13 02:08:29 UTC

[solr] branch main updated: [SOLR-16461] Create v2 equivalent of v1 ReplicationHandler 'BACKUP' (#1119)

This is an automated email from the ASF dual-hosted git repository.

stillalex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new 10795898433 [SOLR-16461] Create v2 equivalent of v1 ReplicationHandler 'BACKUP' (#1119)
10795898433 is described below

commit 107958984333c9650a6124a9751a1ba725baf387
Author: Sanjay Dutt <sa...@gmail.com>
AuthorDate: Wed Sep 13 07:38:23 2023 +0530

    [SOLR-16461] Create v2 equivalent of v1 ReplicationHandler 'BACKUP' (#1119)
---
 solr/CHANGES.txt                                   |   3 +
 .../java/org/apache/solr/api/JerseyResource.java   |   7 +
 .../apache/solr/handler/ReplicationHandler.java    | 142 ++++++++++-----
 .../solr/handler/admin/api/SnapshotBackupAPI.java  | 194 +++++++++++++++++++++
 .../org/apache/solr/jersey/APIConfigProvider.java  |   6 +-
 .../solr/jersey/APIConfigProviderBinder.java       |   5 +-
 .../handler/admin/api/SnapshotBackupAPITest.java   | 148 ++++++++++++++++
 7 files changed, 452 insertions(+), 53 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index d9b09f16e59..c608bf4f314 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -125,6 +125,9 @@ Improvements
 
 * SOLR-14886: Suppress stack traces in query response (Isabelle Giguere via Alex Deparvu)
 
+* SOLR-16461: `/solr/coreName/replication?command=backup` now has a v2 equivalent, available at
+  `/api/cores/coreName/replication/backups` (Sanjay Dutt, Jason Gerlowski, Alex Deparvu)
+
 Optimizations
 ---------------------
 
diff --git a/solr/core/src/java/org/apache/solr/api/JerseyResource.java b/solr/core/src/java/org/apache/solr/api/JerseyResource.java
index 6a304654e27..b7ff482fd1c 100644
--- a/solr/core/src/java/org/apache/solr/api/JerseyResource.java
+++ b/solr/core/src/java/org/apache/solr/api/JerseyResource.java
@@ -103,4 +103,11 @@ public class JerseyResource {
           SolrException.ErrorCode.BAD_REQUEST, "Missing required parameter: " + parameterName);
     }
   }
+
+  protected void ensureRequiredRequestBodyProvided(Object requestBody) {
+    if (requestBody == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST, "Required request-body is missing");
+    }
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
index 9f6d09bab22..ab40ff02543 100644
--- a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
@@ -50,6 +50,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.function.BiConsumer;
+import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.zip.Adler32;
@@ -90,8 +91,12 @@ import org.apache.solr.core.SolrEventListener;
 import org.apache.solr.core.backup.repository.BackupRepository;
 import org.apache.solr.core.backup.repository.LocalFileSystemRepository;
 import org.apache.solr.handler.IndexFetcher.IndexFetchResult;
+import org.apache.solr.handler.ReplicationHandler.ReplicationHandlerConfig;
 import org.apache.solr.handler.admin.api.CoreReplicationAPI;
+import org.apache.solr.handler.admin.api.SnapshotBackupAPI;
 import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.APIConfigProvider;
+import org.apache.solr.jersey.APIConfigProvider.APIConfig;
 import org.apache.solr.metrics.MetricsMap;
 import org.apache.solr.metrics.SolrMetricsContext;
 import org.apache.solr.request.SolrQueryRequest;
@@ -137,7 +142,8 @@ import org.slf4j.MDC;
  *
  * @since solr 1.4
  */
-public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAware {
+public class ReplicationHandler extends RequestHandlerBase
+    implements SolrCoreAware, APIConfigProvider<ReplicationHandlerConfig> {
 
   public static final String PATH = "/replication";
 
@@ -217,8 +223,6 @@ public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAw
 
   private volatile long executorStartTime;
 
-  private int numberBackupsToKeep = 0; // zero: do not delete old backups
-
   private int numTimesReplicated = 0;
 
   private final Map<String, FileInfo> confFileInfoCache = new HashMap<>();
@@ -236,6 +240,8 @@ public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAw
 
   private PollListener pollListener;
 
+  private final ReplicationHandlerConfig replicationHandlerConfig = new ReplicationHandlerConfig();
+
   public interface PollListener {
     void onComplete(SolrCore solrCore, IndexFetchResult fetchResult) throws IOException;
   }
@@ -593,50 +599,19 @@ public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAw
   private void doSnapShoot(SolrParams params, SolrQueryResponse rsp, SolrQueryRequest req) {
     try {
       int numberToKeep = params.getInt(NUMBER_BACKUPS_TO_KEEP_REQUEST_PARAM, 0);
-      if (numberToKeep > 0 && numberBackupsToKeep > 0) {
-        throw new SolrException(
-            ErrorCode.BAD_REQUEST,
-            "Cannot use "
-                + NUMBER_BACKUPS_TO_KEEP_REQUEST_PARAM
-                + " if "
-                + NUMBER_BACKUPS_TO_KEEP_INIT_PARAM
-                + " was specified in the configuration.");
-      }
-      numberToKeep = Math.max(numberToKeep, numberBackupsToKeep);
-      if (numberToKeep < 1) {
-        numberToKeep = Integer.MAX_VALUE;
-      }
-
       String location = params.get(CoreAdminParams.BACKUP_LOCATION);
       String repoName = params.get(CoreAdminParams.BACKUP_REPOSITORY);
-      CoreContainer cc = core.getCoreContainer();
-      BackupRepository repo = null;
-      if (repoName != null) {
-        repo = cc.newBackupRepository(repoName);
-        location = repo.getBackupLocation(location);
-        if (location == null) {
-          throw new IllegalArgumentException("location is required");
-        }
-      } else {
-        repo = new LocalFileSystemRepository();
-        if (location == null) {
-          location = core.getDataDir();
-        } else {
-          location =
-              core.getCoreDescriptor().getInstanceDir().resolve(location).normalize().toString();
-        }
-      }
-      if ("file".equals(repo.createURI("x").getScheme())) {
-        core.getCoreContainer().assertPathAllowed(Paths.get(location));
-      }
-
-      // small race here before the commit point is saved
-      URI locationUri = repo.createDirectoryURI(location);
       String commitName = params.get(CoreAdminParams.COMMIT_NAME);
-      SnapShooter snapShooter =
-          new SnapShooter(repo, core, locationUri, params.get(NAME), commitName);
-      snapShooter.validateCreateSnapshot();
-      snapShooter.createSnapAsync(numberToKeep, (nl) -> snapShootDetails = nl);
+      String name = params.get(NAME);
+      doSnapShoot(
+          numberToKeep,
+          replicationHandlerConfig.numberBackupsToKeep,
+          location,
+          repoName,
+          commitName,
+          name,
+          core,
+          (nl) -> snapShootDetails = nl);
       rsp.add(STATUS, OK_STATUS);
     } catch (SolrException e) {
       throw e;
@@ -647,6 +622,58 @@ public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAw
     }
   }
 
+  public static void doSnapShoot(
+      int numberToKeep,
+      int numberBackupsToKeep,
+      String location,
+      String repoName,
+      String commitName,
+      String name,
+      SolrCore core,
+      Consumer<NamedList<?>> result)
+      throws IOException {
+    if (numberToKeep > 0 && numberBackupsToKeep > 0) {
+      throw new SolrException(
+          ErrorCode.BAD_REQUEST,
+          "Cannot use "
+              + NUMBER_BACKUPS_TO_KEEP_REQUEST_PARAM
+              + " if "
+              + NUMBER_BACKUPS_TO_KEEP_INIT_PARAM
+              + " was specified in the configuration.");
+    }
+    numberToKeep = Math.max(numberToKeep, numberBackupsToKeep);
+    if (numberToKeep < 1) {
+      numberToKeep = Integer.MAX_VALUE;
+    }
+
+    CoreContainer cc = core.getCoreContainer();
+    BackupRepository repo = null;
+    if (repoName != null) {
+      repo = cc.newBackupRepository(repoName);
+      location = repo.getBackupLocation(location);
+      if (location == null) {
+        throw new IllegalArgumentException("location is required");
+      }
+    } else {
+      repo = new LocalFileSystemRepository();
+      if (location == null) {
+        location = core.getDataDir();
+      } else {
+        location =
+            core.getCoreDescriptor().getInstanceDir().resolve(location).normalize().toString();
+      }
+    }
+    if ("file".equals(repo.createURI("x").getScheme())) {
+      core.getCoreContainer().assertPathAllowed(Paths.get(location));
+    }
+
+    // small race here before the commit point is saved
+    URI locationUri = repo.createDirectoryURI(location);
+    SnapShooter snapShooter = new SnapShooter(repo, core, locationUri, name, commitName);
+    snapShooter.validateCreateSnapshot();
+    snapShooter.createSnapAsync(numberToKeep, result);
+  }
+
   /**
    * This method adds an Object of FileStream to the response . The FileStream implements a custom
    * protocol which is understood by IndexFetcher.FileFetcher
@@ -1239,9 +1266,9 @@ public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAw
     registerCloseHook();
     Object nbtk = initArgs.get(NUMBER_BACKUPS_TO_KEEP_INIT_PARAM);
     if (nbtk != null) {
-      numberBackupsToKeep = Integer.parseInt(nbtk.toString());
+      replicationHandlerConfig.numberBackupsToKeep = Integer.parseInt(nbtk.toString());
     } else {
-      numberBackupsToKeep = 0;
+      replicationHandlerConfig.numberBackupsToKeep = 0;
     }
     NamedList<?> follower = getObjectWithBackwardCompatibility(initArgs, "follower", "slave");
     boolean enableFollower = isEnabled(follower);
@@ -1374,7 +1401,7 @@ public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAw
 
   @Override
   public Collection<Class<? extends JerseyResource>> getJerseyResources() {
-    return List.of(CoreReplicationAPI.class);
+    return List.of(CoreReplicationAPI.class, SnapshotBackupAPI.class);
   }
 
   @Override
@@ -1474,7 +1501,7 @@ public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAw
         }
         if (snapshoot) {
           try {
-            int numberToKeep = numberBackupsToKeep;
+            int numberToKeep = replicationHandlerConfig.numberBackupsToKeep;
             if (numberToKeep < 1) {
               numberToKeep = Integer.MAX_VALUE;
             }
@@ -1898,4 +1925,23 @@ public class ReplicationHandler extends RequestHandlerBase implements SolrCoreAw
    * @lucene.internal
    */
   public static final String WAIT = "wait";
+
+  public static class ReplicationHandlerConfig implements APIConfig {
+
+    private int numberBackupsToKeep = 0; // zero: do not delete old backups
+
+    public int getNumberBackupsToKeep() {
+      return numberBackupsToKeep;
+    }
+  }
+
+  @Override
+  public ReplicationHandlerConfig provide() {
+    return replicationHandlerConfig;
+  }
+
+  @Override
+  public Class<ReplicationHandlerConfig> getConfigClass() {
+    return ReplicationHandlerConfig.class;
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/SnapshotBackupAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/SnapshotBackupAPI.java
new file mode 100644
index 00000000000..4002db925c0
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/SnapshotBackupAPI.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.solr.handler.admin.api;
+
+import static org.apache.solr.client.solrj.impl.BinaryResponseParser.BINARY_CONTENT_TYPE_V2;
+import static org.apache.solr.handler.ReplicationHandler.ERR_STATUS;
+import static org.apache.solr.security.PermissionNameProvider.Name.CORE_EDIT_PERM;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.function.Consumer;
+import javax.inject.Inject;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.apache.solr.api.JerseyResource;
+import org.apache.solr.client.api.model.SolrJerseyResponse;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.annotation.JsonProperty;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.handler.ReplicationHandler;
+import org.apache.solr.handler.ReplicationHandler.ReplicationHandlerConfig;
+import org.apache.solr.jersey.JacksonReflectMapWriter;
+import org.apache.solr.jersey.PermissionName;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** V2 endpoint for Backup API used for User-Managed clusters and Single-Node Installation. */
+@Path("/cores/{coreName}/replication/backups")
+public class SnapshotBackupAPI extends JerseyResource {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private final SolrCore solrCore;
+  private final ReplicationHandlerConfig replicationHandlerConfig;
+
+  @Inject
+  public SnapshotBackupAPI(SolrCore solrCore, ReplicationHandlerConfig replicationHandlerConfig) {
+    this.solrCore = solrCore;
+    this.replicationHandlerConfig = replicationHandlerConfig;
+  }
+
+  /**
+   * This API (POST /api/cores/coreName/replication/backups {...}) is analogous to the v1
+   * /solr/coreName/replication?command=backup
+   */
+  @POST
+  @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, BINARY_CONTENT_TYPE_V2})
+  @Operation(summary = "Backup command using ReplicationHandler")
+  @PermissionName(CORE_EDIT_PERM)
+  public BackupReplicationResponse createBackup(
+      @RequestBody BackupReplicationRequestBody backupReplicationPayload) throws Exception {
+    ensureRequiredRequestBodyProvided(backupReplicationPayload);
+    ReplicationHandler replicationHandler =
+        (ReplicationHandler) solrCore.getRequestHandler(ReplicationHandler.PATH);
+    return doBackup(replicationHandler, backupReplicationPayload);
+  }
+
+  private BackupReplicationResponse doBackup(
+      ReplicationHandler replicationHandler,
+      BackupReplicationRequestBody backupReplicationPayload) {
+    BackupReplicationResponse response = instantiateJerseyResponse(BackupReplicationResponse.class);
+    int numberToKeep = backupReplicationPayload.numberToKeep;
+    int numberBackupsToKeep = replicationHandlerConfig.getNumberBackupsToKeep();
+    String location = backupReplicationPayload.location;
+    String repoName = backupReplicationPayload.repository;
+    String commitName = backupReplicationPayload.commitName;
+    String name = backupReplicationPayload.name;
+    Consumer<NamedList<?>> resultConsumer = result -> response.result = result;
+    try {
+      doSnapShoot(
+          numberToKeep,
+          numberBackupsToKeep,
+          location,
+          repoName,
+          commitName,
+          name,
+          solrCore,
+          resultConsumer);
+      response.status = ReplicationHandler.OK_STATUS;
+    } catch (SolrException e) {
+      throw e;
+    } catch (Exception e) {
+      log.error("Exception while creating a snapshot", e);
+      reportErrorOnResponse(
+          response, "Error encountered while creating a snapshot: " + e.getMessage(), e);
+    }
+    return response;
+  }
+
+  /** Separate method helps with testing */
+  protected void doSnapShoot(
+      int numberToKeep,
+      int numberBackupsToKeep,
+      String location,
+      String repoName,
+      String commitName,
+      String name,
+      SolrCore solrCore,
+      Consumer<NamedList<?>> resultConsumer)
+      throws IOException {
+    ReplicationHandler.doSnapShoot(
+        numberToKeep,
+        numberBackupsToKeep,
+        location,
+        repoName,
+        commitName,
+        name,
+        solrCore,
+        resultConsumer);
+  }
+
+  /* POJO for v2 endpoints request body. */
+  public static class BackupReplicationRequestBody implements JacksonReflectMapWriter {
+
+    public BackupReplicationRequestBody() {}
+
+    public BackupReplicationRequestBody(
+        String location, String name, int numberToKeep, String repository, String commitName) {
+      this.location = location;
+      this.name = name;
+      this.numberToKeep = numberToKeep;
+      this.repository = repository;
+      this.commitName = commitName;
+    }
+
+    @Schema(description = "The path where the backup will be created")
+    @JsonProperty
+    public String location;
+
+    @Schema(description = "The backup will be created in a directory called snapshot.<name>")
+    @JsonProperty
+    public String name;
+
+    @Schema(description = "The number of backups to keep.")
+    @JsonProperty
+    public int numberToKeep;
+
+    @Schema(description = "The name of the repository to be used for e backup.")
+    @JsonProperty
+    public String repository;
+
+    @Schema(
+        description =
+            "The name of the commit which was used while taking a snapshot using the CREATESNAPSHOT command.")
+    @JsonProperty
+    public String commitName;
+  }
+
+  /** Response for {@link SnapshotBackupAPI#createBackup(BackupReplicationRequestBody)}. */
+  public static class BackupReplicationResponse extends SolrJerseyResponse {
+
+    @JsonProperty("result")
+    public NamedList<?> result;
+
+    @JsonProperty("status")
+    public String status;
+
+    @JsonProperty("message")
+    public String message;
+
+    @JsonProperty("exception")
+    public Exception exception;
+
+    public BackupReplicationResponse() {}
+  }
+
+  private static void reportErrorOnResponse(
+      BackupReplicationResponse response, String message, Exception e) {
+    response.status = ERR_STATUS;
+    response.message = message;
+    if (e != null) {
+      response.exception = e;
+    }
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/jersey/APIConfigProvider.java b/solr/core/src/java/org/apache/solr/jersey/APIConfigProvider.java
index fd250949ffd..cd6a2f6f12b 100644
--- a/solr/core/src/java/org/apache/solr/jersey/APIConfigProvider.java
+++ b/solr/core/src/java/org/apache/solr/jersey/APIConfigProvider.java
@@ -17,16 +17,14 @@
 package org.apache.solr.jersey;
 
 import org.apache.solr.jersey.APIConfigProvider.APIConfig;
-import org.glassfish.hk2.api.Factory;
 
 /**
  * Interface to be implemented by the Request Handlers that need to provide some custom
  * configuration to the V2 APIs
  */
-public interface APIConfigProvider<T extends APIConfig> extends Factory<T> {
+public interface APIConfigProvider<T extends APIConfig> {
 
-  @Override
-  default void dispose(T instance) {}
+  T provide();
 
   Class<T> getConfigClass();
 
diff --git a/solr/core/src/java/org/apache/solr/jersey/APIConfigProviderBinder.java b/solr/core/src/java/org/apache/solr/jersey/APIConfigProviderBinder.java
index 8e4258aa43d..d17ea7ded13 100644
--- a/solr/core/src/java/org/apache/solr/jersey/APIConfigProviderBinder.java
+++ b/solr/core/src/java/org/apache/solr/jersey/APIConfigProviderBinder.java
@@ -16,6 +16,7 @@
  */
 package org.apache.solr.jersey;
 
+import javax.inject.Singleton;
 import org.glassfish.hk2.utilities.binding.AbstractBinder;
 
 /** Jersey binder for APIConfigProvider */
@@ -29,6 +30,8 @@ public class APIConfigProviderBinder extends AbstractBinder {
 
   @Override
   protected void configure() {
-    bindFactory(cfgProvider).to(cfgProvider.getConfigClass());
+    bindFactory(new InjectionFactories.SingletonFactory<>(cfgProvider.provide()))
+        .to(cfgProvider.getConfigClass())
+        .in(Singleton.class);
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SnapshotBackupAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SnapshotBackupAPITest.java
new file mode 100644
index 00000000000..84bba8148b0
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SnapshotBackupAPITest.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.solr.handler.admin.api;
+
+import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import javax.inject.Inject;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.handler.ReplicationHandler.ReplicationHandlerConfig;
+import org.apache.solr.jersey.InjectionFactories;
+import org.apache.solr.jersey.SolrJacksonMapper;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+import org.glassfish.jersey.process.internal.RequestScoped;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Unit tests for {@link SnapshotBackupAPI}. */
+public class SnapshotBackupAPITest extends JerseyTest {
+
+  private SolrCore solrCore;
+  private ReplicationHandlerConfig replicationHandlerConfig;
+
+  @BeforeClass
+  public static void ensureWorkingMockito() {
+    assumeWorkingMockito();
+  }
+
+  @Override
+  protected Application configure() {
+    resetMocks();
+    final ResourceConfig config = new ResourceConfig();
+    config.register(TestSnapshotBackupAPI.class);
+    config.register(SolrJacksonMapper.class);
+    config.register(SolrExceptionTestMapper.class);
+    config.register(
+        new AbstractBinder() {
+          @Override
+          protected void configure() {
+            bindFactory(new InjectionFactories.SingletonFactory<>(solrCore))
+                .to(SolrCore.class)
+                .in(RequestScoped.class);
+          }
+        });
+    config.register(
+        new AbstractBinder() {
+          @Override
+          protected void configure() {
+            bindFactory(new InjectionFactories.SingletonFactory<>(replicationHandlerConfig))
+                .to(ReplicationHandlerConfig.class)
+                .in(RequestScoped.class);
+          }
+        });
+    return config;
+  }
+
+  @Test
+  public void testMissingBody() throws Exception {
+    final Response response = target("/cores/demo/replication/backups").request().post(null);
+    var status = response.getStatusInfo();
+    assertEquals(400, status.getStatusCode());
+    assertEquals("Required request-body is missing", status.getReasonPhrase());
+  }
+
+  @Test
+  public void testSuccessfulBackupCommand() throws Exception {
+    int numberToKeep = 7;
+    int numberBackupsToKeep = 11;
+
+    when(replicationHandlerConfig.getNumberBackupsToKeep()).thenReturn(numberBackupsToKeep);
+    final Response response =
+        target("/cores/demo/replication/backups")
+            .request()
+            .post(Entity.json("{\"name\": \"test\", \"numberToKeep\": " + numberToKeep + "}"));
+    System.err.println("RESP " + response);
+
+    assertEquals(numberToKeep, TestSnapshotBackupAPI.numberToKeep.get());
+    assertEquals(numberBackupsToKeep, TestSnapshotBackupAPI.numberBackupsToKeep.get());
+    assertEquals(200, response.getStatus());
+  }
+
+  private void resetMocks() {
+    solrCore = mock(SolrCore.class);
+    replicationHandlerConfig = mock(ReplicationHandlerConfig.class);
+    when(replicationHandlerConfig.getNumberBackupsToKeep()).thenReturn(5);
+  }
+
+  public static class SolrExceptionTestMapper implements ExceptionMapper<SolrException> {
+    @Override
+    public Response toResponse(SolrException e) {
+      return Response.status(e.code(), e.getMessage()).build();
+    }
+  }
+
+  private static class TestSnapshotBackupAPI extends SnapshotBackupAPI {
+
+    private static final AtomicInteger numberToKeep = new AtomicInteger();
+    private static final AtomicInteger numberBackupsToKeep = new AtomicInteger();
+
+    @Inject
+    public TestSnapshotBackupAPI(
+        SolrCore solrCore, ReplicationHandlerConfig replicationHandlerConfig) {
+      super(solrCore, replicationHandlerConfig);
+    }
+
+    @Override
+    protected void doSnapShoot(
+        int numberToKeep,
+        int numberBackupsToKeep,
+        String location,
+        String repoName,
+        String commitName,
+        String name,
+        SolrCore solrCore,
+        Consumer<NamedList<?>> resultConsumer)
+        throws IOException {
+      TestSnapshotBackupAPI.numberToKeep.set(numberToKeep);
+      TestSnapshotBackupAPI.numberBackupsToKeep.set(numberBackupsToKeep);
+    }
+  }
+}