You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ge...@apache.org on 2023/06/17 15:42:20 UTC

[solr] branch branch_9x updated (8c070ef8085 -> a514a2d929d)

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

gerlowskija pushed a change to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git


    from 8c070ef8085 SOLR-16806: Create a BalanceReplicas API (#1650)
     new 324731e0869 SOLR-16392: Tweak v2 ADDREPLICA to be more REST-ful
     new 7c71f99071d SOLR-16398: Tweak 4 v2 collection APIs to be more REST-ful (#1683)
     new a514a2d929d SOLR-16398: Tweak v2 B-S-U API to be more REST-ful (#1689)

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 solr/CHANGES.txt                                   |  13 ++
 ...istributedCollectionConfigSetCommandRunner.java |   7 +-
 .../solr/handler/admin/CollectionsHandler.java     | 206 +++-----------------
 .../solr/handler/admin/api/AddReplicaAPI.java      |  72 -------
 .../handler/admin/api/BalanceShardUniqueAPI.java   | 151 ++++++++++++---
 .../{CreateShardAPI.java => CreateReplicaAPI.java} | 211 +++++++++------------
 .../solr/handler/admin/api/ForceLeaderAPI.java     | 184 ++++++++++++++----
 .../handler/admin/api/ReloadCollectionAPI.java     | 108 ++++++++---
 .../handler/admin/api/RenameCollectionAPI.java     | 141 ++++++++------
 .../solr/handler/admin/api/SyncShardAPI.java       | 117 ++++++++----
 .../org/apache/solr/cloud/TestPullReplica.java     |   8 +-
 .../org/apache/solr/cloud/TestTlogReplica.java     |  13 +-
 .../solr/handler/admin/TestApiFramework.java       |  16 +-
 .../solr/handler/admin/TestCollectionAPIs.java     |  39 ----
 .../admin/api/BalanceShardUniqueAPITest.java       |  98 ++++++++++
 ...ShardAPITest.java => CreateReplicaAPITest.java} | 102 +++++-----
 .../admin/api/ForceLeaderAPITest.java}             |  45 +++--
 .../handler/admin/api/ReloadCollectionAPITest.java |  57 ++++++
 .../admin/api/SyncShardAPITest.java}               |  45 +++--
 .../admin/api/V2CollectionAPIMappingTest.java      |  51 -----
 .../handler/admin/api/V2ShardsAPIMappingTest.java  |  72 -------
 .../solr/util/tracing/TestDistributedTracing.java  |   6 +-
 .../pages/cluster-node-management.adoc             |   6 +-
 .../pages/collection-management.adoc               |  27 +--
 .../deployment-guide/pages/replica-management.adoc |  20 +-
 .../deployment-guide/pages/shard-management.adoc   |   6 +-
 .../solrj/request/beans/AddReplicaPayload.java     |  56 ------
 .../request/beans/BalanceShardUniquePayload.java   |  29 ---
 .../solrj/request/beans/ForceLeaderPayload.java    |  25 ---
 .../request/beans/ReloadCollectionPayload.java     |  24 ---
 .../request/beans/RenameCollectionPayload.java     |  29 ---
 .../solrj/request/beans/SyncShardPayload.java      |  23 ---
 32 files changed, 941 insertions(+), 1066 deletions(-)
 delete mode 100644 solr/core/src/java/org/apache/solr/handler/admin/api/AddReplicaAPI.java
 copy solr/core/src/java/org/apache/solr/handler/admin/api/{CreateShardAPI.java => CreateReplicaAPI.java} (56%)
 create mode 100644 solr/core/src/test/org/apache/solr/handler/admin/api/BalanceShardUniqueAPITest.java
 copy solr/core/src/test/org/apache/solr/handler/admin/api/{CreateShardAPITest.java => CreateReplicaAPITest.java} (62%)
 copy solr/core/src/test/org/apache/solr/{update/AnalysisErrorHandlingTest.java => handler/admin/api/ForceLeaderAPITest.java} (51%)
 create mode 100644 solr/core/src/test/org/apache/solr/handler/admin/api/ReloadCollectionAPITest.java
 copy solr/core/src/test/org/apache/solr/{update/AnalysisErrorHandlingTest.java => handler/admin/api/SyncShardAPITest.java} (51%)
 delete mode 100644 solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/AddReplicaPayload.java
 delete mode 100644 solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/BalanceShardUniquePayload.java
 delete mode 100644 solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/ForceLeaderPayload.java
 delete mode 100644 solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/ReloadCollectionPayload.java
 delete mode 100644 solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/RenameCollectionPayload.java
 delete mode 100644 solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/SyncShardPayload.java


[solr] 03/03: SOLR-16398: Tweak v2 B-S-U API to be more REST-ful (#1689)

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit a514a2d929d6a3b6ca29e81f16e487396e059771
Author: Jason Gerlowski <ge...@apache.org>
AuthorDate: Thu Jun 8 14:18:29 2023 -0400

    SOLR-16398: Tweak v2 B-S-U API to be more REST-ful (#1689)
    
    This commit changes the v2 "balance-shard-unique" replica to be more
    in line with the REST-ful design we're targeting for Solr's v2 APIs.
    
    Following these changes, the v2 API now appears as:
    
      `POST /api/collections/cName/balance-shard-unique {...}`
    
    Although not shown above, the 'balance-shard-unique' command
    specifier has also been removed from the request body.
    
    This commit also converts the API to the new JAX-RS framework.
---
 solr/CHANGES.txt                                   |   4 +
 .../solr/handler/admin/CollectionsHandler.java     |  27 +---
 .../handler/admin/api/BalanceShardUniqueAPI.java   | 151 +++++++++++++++++----
 .../solr/handler/admin/TestCollectionAPIs.java     |   7 -
 .../admin/api/BalanceShardUniqueAPITest.java       |  98 +++++++++++++
 .../admin/api/V2CollectionAPIMappingTest.java      |  21 ---
 .../pages/cluster-node-management.adoc             |   6 +-
 .../request/beans/BalanceShardUniquePayload.java   |  29 ----
 8 files changed, 229 insertions(+), 114 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 7b8e9eb0450..fc9a9703ed7 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -138,6 +138,10 @@ Improvements
   "SYNCSHARD" is now available at `POST /api/collections/cName/shards/sName/sync-shard`, and "RENAME" is now available at
   `POST /api/collections/cName/rename`. (Jason Gerlowski)
 
+* SOLR-16398: The v2 "balance-shard-unique" API has been tweaked to be more intuitive, by removing the top-level command
+  specifier from the request body, and changing the path. The v2 functionality can now be accessed at:
+  `POST /api/collections/cName/balance-shard-unique {...}` (Jason Gerlowski)
+
 Optimizations
 ---------------------
 
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
index eef39bee14d..9413389ca92 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
@@ -25,7 +25,6 @@ import static org.apache.solr.cloud.Overseer.QUEUE_OPERATION;
 import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.CREATE_NODE_SET;
 import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.CREATE_NODE_SET_SHUFFLE;
 import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.NUM_SLICES;
-import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.ONLY_ACTIVE_NODES;
 import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.REQUESTID;
 import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.SHARD_UNIQUE;
 import static org.apache.solr.common.SolrException.ErrorCode.BAD_REQUEST;
@@ -115,7 +114,6 @@ import java.util.Collections;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -135,7 +133,6 @@ import org.apache.solr.cloud.ZkController;
 import org.apache.solr.cloud.ZkController.NotInClusterStateException;
 import org.apache.solr.cloud.api.collections.DistributedCollectionConfigSetCommandRunner;
 import org.apache.solr.cloud.api.collections.ReindexCollectionCmd;
-import org.apache.solr.cloud.overseer.SliceMutator;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrException.ErrorCode;
 import org.apache.solr.common.cloud.ClusterProperties;
@@ -1025,26 +1022,8 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
     BALANCESHARDUNIQUE_OP(
         BALANCESHARDUNIQUE,
         (req, rsp, h) -> {
-          Map<String, Object> map =
-              copy(req.getParams().required(), null, COLLECTION_PROP, PROPERTY_PROP);
-          Boolean shardUnique = Boolean.parseBoolean(req.getParams().get(SHARD_UNIQUE));
-          String prop = req.getParams().get(PROPERTY_PROP).toLowerCase(Locale.ROOT);
-          if (!prop.startsWith(PROPERTY_PREFIX)) {
-            prop = PROPERTY_PREFIX + prop;
-          }
-
-          if (!shardUnique && !SliceMutator.SLICE_UNIQUE_BOOLEAN_PROPERTIES.contains(prop)) {
-            throw new SolrException(
-                ErrorCode.BAD_REQUEST,
-                "Balancing properties amongst replicas in a slice requires that"
-                    + " the property be pre-defined as a unique property (e.g. 'preferredLeader') or that 'shardUnique' be set to 'true'. "
-                    + " Property: "
-                    + prop
-                    + " shardUnique: "
-                    + shardUnique);
-          }
-
-          return copy(req.getParams(), map, ONLY_ACTIVE_NODES, SHARD_UNIQUE);
+          BalanceShardUniqueAPI.invokeFromV1Params(h.coreContainer, req, rsp);
+          return null;
         }),
     REBALANCELEADERS_OP(
         REBALANCELEADERS,
@@ -1385,6 +1364,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
     return List.of(
         CreateReplicaAPI.class,
         AddReplicaPropertyAPI.class,
+        BalanceShardUniqueAPI.class,
         CreateAliasAPI.class,
         CreateCollectionAPI.class,
         CreateCollectionBackupAPI.class,
@@ -1418,7 +1398,6 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
   public Collection<Api> getApis() {
     final List<Api> apis = new ArrayList<>();
     apis.addAll(AnnotatedApi.getApis(new SplitShardAPI(this)));
-    apis.addAll(AnnotatedApi.getApis(new BalanceShardUniqueAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new MigrateDocsAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new ModifyCollectionAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new MoveReplicaAPI(this)));
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/BalanceShardUniqueAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/BalanceShardUniqueAPI.java
index 36dbd28ce3e..64f31ac2e59 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/BalanceShardUniqueAPI.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/BalanceShardUniqueAPI.java
@@ -16,50 +16,143 @@
  */
 package org.apache.solr.handler.admin.api;
 
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST;
-import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
-import static org.apache.solr.common.params.CommonParams.ACTION;
-import static org.apache.solr.handler.ClusterAPI.wrapParams;
+import static org.apache.solr.client.solrj.impl.BinaryResponseParser.BINARY_CONTENT_TYPE_V2;
+import static org.apache.solr.cloud.Overseer.QUEUE_OPERATION;
+import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.ONLY_ACTIVE_NODES;
+import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.SHARD_UNIQUE;
+import static org.apache.solr.common.cloud.ZkStateReader.COLLECTION_PROP;
+import static org.apache.solr.common.cloud.ZkStateReader.PROPERTY_PROP;
+import static org.apache.solr.common.params.CollectionAdminParams.PROPERTY_PREFIX;
+import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
 import static org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
 
 import java.util.HashMap;
+import java.util.Locale;
 import java.util.Map;
-import org.apache.solr.api.Command;
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.api.PayloadObj;
-import org.apache.solr.client.solrj.request.beans.BalanceShardUniquePayload;
+import javax.inject.Inject;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import org.apache.solr.cloud.overseer.SliceMutator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.annotation.JsonProperty;
+import org.apache.solr.common.cloud.ZkNodeProps;
 import org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.handler.admin.CollectionsHandler;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.JacksonReflectMapWriter;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SubResponseAccumulatingJerseyResponse;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
 
 /**
  * V2 API for insuring that a particular property is distributed evenly amongst the physical nodes
  * comprising a collection.
  *
- * <p>The new API (POST /v2/collections/collectionName {'balance-shard-unique': {...}}) is analogous
- * to the v1 /admin/collections?action=BALANCESHARDUNIQUE command.
- *
- * @see BalanceShardUniquePayload
+ * <p>The new API (POST /v2/collections/collectionName/balance-shard-unique {...} ) is analogous to
+ * the v1 /admin/collections?action=BALANCESHARDUNIQUE command.
  */
-@EndPoint(
-    path = {"/c/{collection}", "/collections/{collection}"},
-    method = POST,
-    permission = COLL_EDIT_PERM)
-public class BalanceShardUniqueAPI {
-  private static final String V2_BALANCE_SHARD_UNIQUE_CMD = "balance-shard-unique";
+@Path("/collections/{collectionName}/balance-shard-unique")
+public class BalanceShardUniqueAPI extends AdminAPIBase {
+  @Inject
+  public BalanceShardUniqueAPI(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse) {
+    super(coreContainer, solrQueryRequest, solrQueryResponse);
+  }
+
+  @POST
+  @Produces({"application/json", "application/xml", BINARY_CONTENT_TYPE_V2})
+  @PermissionName(COLL_EDIT_PERM)
+  public SubResponseAccumulatingJerseyResponse balanceShardUnique(
+      @PathParam("collectionName") String collectionName, BalanceShardUniqueRequestBody requestBody)
+      throws Exception {
+    final var response = instantiateJerseyResponse(SubResponseAccumulatingJerseyResponse.class);
+    if (requestBody == null) {
+      throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Missing required request body");
+    }
+    ensureRequiredParameterProvided(COLLECTION_PROP, collectionName);
+    ensureRequiredParameterProvided(PROPERTY_PROP, requestBody.property);
+    validatePropertyToBalance(requestBody.property, Boolean.TRUE.equals(requestBody.shardUnique));
+    fetchAndValidateZooKeeperAwareCoreContainer();
+    recordCollectionForLogAndTracing(collectionName, solrQueryRequest);
+
+    final ZkNodeProps remoteMessage = createRemoteMessage(collectionName, requestBody);
+    submitRemoteMessageAndHandleResponse(
+        response,
+        CollectionParams.CollectionAction.BALANCESHARDUNIQUE,
+        remoteMessage,
+        requestBody.asyncId);
+
+    return response;
+  }
+
+  public static ZkNodeProps createRemoteMessage(
+      String collectionName, BalanceShardUniqueRequestBody requestBody) {
+    final Map<String, Object> remoteMessage = new HashMap<>();
+    remoteMessage.put(
+        QUEUE_OPERATION, CollectionParams.CollectionAction.BALANCESHARDUNIQUE.toLower());
+    remoteMessage.put(COLLECTION_PROP, collectionName);
+    remoteMessage.put(PROPERTY_PROP, requestBody.property);
+    insertIfNotNull(remoteMessage, ONLY_ACTIVE_NODES, requestBody.onlyActiveNodes);
+    insertIfNotNull(remoteMessage, SHARD_UNIQUE, requestBody.shardUnique);
+    insertIfNotNull(remoteMessage, ASYNC, requestBody.asyncId);
+
+    return new ZkNodeProps(remoteMessage);
+  }
+
+  public static void invokeFromV1Params(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse)
+      throws Exception {
+    final var api = new BalanceShardUniqueAPI(coreContainer, solrQueryRequest, solrQueryResponse);
+    final SolrParams params = solrQueryRequest.getParams();
+    params.required().check(COLLECTION_PROP, PROPERTY_PROP);
+
+    final String collection = params.get(COLLECTION_PROP);
+    final var requestBody = new BalanceShardUniqueRequestBody();
+    requestBody.property = params.get(PROPERTY_PROP);
+    requestBody.onlyActiveNodes = params.getBool(ONLY_ACTIVE_NODES);
+    requestBody.shardUnique = params.getBool(SHARD_UNIQUE);
+    requestBody.asyncId = params.get(ASYNC);
+
+    V2ApiUtils.squashIntoSolrResponseWithoutHeader(
+        solrQueryResponse, api.balanceShardUnique(collection, requestBody));
+  }
+
+  public static class BalanceShardUniqueRequestBody implements JacksonReflectMapWriter {
+    @JsonProperty(required = true)
+    public String property;
+
+    @JsonProperty(ONLY_ACTIVE_NODES)
+    public Boolean onlyActiveNodes;
 
-  private final CollectionsHandler collectionsHandler;
+    @JsonProperty public Boolean shardUnique;
 
-  public BalanceShardUniqueAPI(CollectionsHandler collectionsHandler) {
-    this.collectionsHandler = collectionsHandler;
+    @JsonProperty(ASYNC)
+    public String asyncId;
   }
 
-  @Command(name = V2_BALANCE_SHARD_UNIQUE_CMD)
-  public void balanceShardUnique(PayloadObj<BalanceShardUniquePayload> obj) throws Exception {
-    final BalanceShardUniquePayload v2Body = obj.get();
-    final Map<String, Object> v1Params = v2Body.toMap(new HashMap<>());
-    v1Params.put(ACTION, CollectionParams.CollectionAction.BALANCESHARDUNIQUE.toLower());
-    v1Params.put(COLLECTION, obj.getRequest().getPathTemplateValues().get(COLLECTION));
+  private void validatePropertyToBalance(String prop, boolean shardUnique) {
+    prop = prop.toLowerCase(Locale.ROOT);
+    if (!prop.startsWith(PROPERTY_PREFIX)) {
+      prop = PROPERTY_PREFIX + prop;
+    }
 
-    collectionsHandler.handleRequestBody(wrapParams(obj.getRequest(), v1Params), obj.getResponse());
+    if (!shardUnique && !SliceMutator.SLICE_UNIQUE_BOOLEAN_PROPERTIES.contains(prop)) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Balancing properties amongst replicas in a slice requires that"
+              + " the property be pre-defined as a unique property (e.g. 'preferredLeader') or that 'shardUnique' be set to 'true'. "
+              + " Property: "
+              + prop
+              + " shardUnique: "
+              + shardUnique);
+    }
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java b/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
index 4aa4ff96eb2..a8dde0459f5 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
@@ -116,13 +116,6 @@ public class TestCollectionAPIs extends SolrTestCaseJ4 {
         "{remove-role : {role : overseer, node : 'localhost_8978'} }",
         "{operation : removerole ,role : overseer, node : 'localhost_8978'}");
 
-    compareOutput(
-        apiBag,
-        "/collections/coll1",
-        POST,
-        "{balance-shard-unique : {property: preferredLeader} }",
-        "{operation : balanceshardunique ,collection : coll1, property : preferredLeader}");
-
     compareOutput(
         apiBag,
         "/collections/coll1",
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/BalanceShardUniqueAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/BalanceShardUniqueAPITest.java
new file mode 100644
index 00000000000..fea001edcb2
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/BalanceShardUniqueAPITest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.cloud.Overseer.QUEUE_OPERATION;
+import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.ONLY_ACTIVE_NODES;
+import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.SHARD_UNIQUE;
+import static org.apache.solr.common.cloud.ZkStateReader.PROPERTY_PROP;
+import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
+import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
+
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.junit.Test;
+
+/** Unit tests for {@link BalanceShardUniqueAPI} */
+public class BalanceShardUniqueAPITest extends SolrTestCaseJ4 {
+
+  @Test
+  public void testReportsErrorIfRequestBodyMissing() {
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new BalanceShardUniqueAPI(null, null, null);
+              api.balanceShardUnique("someCollectionName", null);
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required request body", thrown.getMessage());
+  }
+
+  @Test
+  public void testReportsErrorIfCollectionNameMissing() {
+    final var requestBody = new BalanceShardUniqueAPI.BalanceShardUniqueRequestBody();
+    requestBody.property = "preferredLeader";
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new BalanceShardUniqueAPI(null, null, null);
+              api.balanceShardUnique(null, requestBody);
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: collection", thrown.getMessage());
+  }
+
+  @Test
+  public void testReportsErrorIfPropertyToBalanceIsMissing() {
+    // Note, 'property' param on reqBody not set
+    final var requestBody = new BalanceShardUniqueAPI.BalanceShardUniqueRequestBody();
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new BalanceShardUniqueAPI(null, null, null);
+              api.balanceShardUnique("someCollName", requestBody);
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: property", thrown.getMessage());
+  }
+
+  @Test
+  public void testCreateRemoteMessageAllProperties() {
+    final var requestBody = new BalanceShardUniqueAPI.BalanceShardUniqueRequestBody();
+    requestBody.property = "someProperty";
+    requestBody.shardUnique = Boolean.TRUE;
+    requestBody.onlyActiveNodes = Boolean.TRUE;
+    requestBody.asyncId = "someAsyncId";
+    final var remoteMessage =
+        BalanceShardUniqueAPI.createRemoteMessage("someCollName", requestBody).getProperties();
+
+    assertEquals(6, remoteMessage.size());
+    assertEquals("balanceshardunique", remoteMessage.get(QUEUE_OPERATION));
+    assertEquals("someCollName", remoteMessage.get(COLLECTION));
+    assertEquals("someProperty", remoteMessage.get(PROPERTY_PROP));
+    assertEquals(Boolean.TRUE, remoteMessage.get(SHARD_UNIQUE));
+    assertEquals(Boolean.TRUE, remoteMessage.get(ONLY_ACTIVE_NODES));
+    assertEquals("someAsyncId", remoteMessage.get(ASYNC));
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/V2CollectionAPIMappingTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/V2CollectionAPIMappingTest.java
index 503be4fa6f0..98d000773c7 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/api/V2CollectionAPIMappingTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/V2CollectionAPIMappingTest.java
@@ -52,7 +52,6 @@ public class V2CollectionAPIMappingTest extends V2ApiMappingTest<CollectionsHand
   @Override
   public void populateApiBag() {
     final CollectionsHandler collectionsHandler = getRequestHandler();
-    apiBag.registerObject(new BalanceShardUniqueAPI(collectionsHandler));
     apiBag.registerObject(new MigrateDocsAPI(collectionsHandler));
     apiBag.registerObject(new ModifyCollectionAPI(collectionsHandler));
     apiBag.registerObject(new MoveReplicaAPI(collectionsHandler));
@@ -161,26 +160,6 @@ public class V2CollectionAPIMappingTest extends V2ApiMappingTest<CollectionsHand
     assertEquals("requestTrackingId", v1Params.get(ASYNC));
   }
 
-  @Test
-  public void testBalanceShardUniqueAllProperties() throws Exception {
-    final SolrParams v1Params =
-        captureConvertedV1Params(
-            "/collections/collName",
-            "POST",
-            "{ 'balance-shard-unique': {"
-                + "'property': 'somePropertyToBalance', "
-                + "'onlyactivenodes': false, "
-                + "'shardUnique': true"
-                + "}}");
-
-    assertEquals(
-        CollectionParams.CollectionAction.BALANCESHARDUNIQUE.lowerName, v1Params.get(ACTION));
-    assertEquals("collName", v1Params.get(COLLECTION));
-    assertEquals("somePropertyToBalance", v1Params.get("property"));
-    assertFalse(v1Params.getPrimitiveBool("onlyactivenodes"));
-    assertTrue(v1Params.getPrimitiveBool("shardUnique"));
-  }
-
   @Test
   public void testRebalanceLeadersAllProperties() throws Exception {
     final SolrParams v1Params =
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cluster-node-management.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cluster-node-management.adoc
index 577eb71eb62..dc84a3239c3 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/cluster-node-management.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cluster-node-management.adoc
@@ -454,11 +454,9 @@ http://localhost:8983/solr/admin/collections?action=BALANCESHARDUNIQUE&collectio
 
 [source,bash]
 ----
-curl -X POST http://localhost:8983/api/collections/techproducts -H 'Content-Type: application/json' -d '
+curl -X POST http://localhost:8983/api/collections/techproducts/balance-shard-unique -H 'Content-Type: application/json' -d '
   {
-    "balance-shard-unique": {
-      "property": "preferredLeader"
-    }
+    "property": "preferredLeader"
   }
 '
 ----
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/BalanceShardUniquePayload.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/BalanceShardUniquePayload.java
deleted file mode 100644
index db3cfef718a..00000000000
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/BalanceShardUniquePayload.java
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * 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.client.solrj.request.beans;
-
-import org.apache.solr.common.annotation.JsonProperty;
-import org.apache.solr.common.util.ReflectMapWriter;
-
-public class BalanceShardUniquePayload implements ReflectMapWriter {
-  @JsonProperty(required = true)
-  public String property;
-
-  @JsonProperty public Boolean onlyactivenodes = null;
-
-  @JsonProperty public Boolean shardUnique;
-}


[solr] 02/03: SOLR-16398: Tweak 4 v2 collection APIs to be more REST-ful (#1683)

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 7c71f99071deb74a48c9a19485dbcb7522c232e6
Author: Jason Gerlowski <ge...@apache.org>
AuthorDate: Tue Jun 6 15:47:35 2023 -0400

    SOLR-16398: Tweak 4 v2 collection APIs to be more REST-ful (#1683)
    
    This commit tweaks several "command"-based v2 APIs to be more REST-ful, namely reload-collection, sync-shard, rename-collection, and force-shard-leader.  The v2 APIs for this functionality is now:
    
    - POST /api/collections/cName/reload {...}
    - POST /api/collections/cName/shards/sName/sync {...}
    - POST /api/collections/cName/rename {...}
    - POST /api/collections/cName/shards/sName/force-leader {...}
    
    This commit also converts these APIs to the JAX-RS framework.
---
 solr/CHANGES.txt                                   |   5 +
 ...istributedCollectionConfigSetCommandRunner.java |   7 +-
 .../solr/handler/admin/CollectionsHandler.java     | 138 +---------------
 .../solr/handler/admin/api/ForceLeaderAPI.java     | 184 +++++++++++++++++----
 .../handler/admin/api/ReloadCollectionAPI.java     | 108 ++++++++----
 .../handler/admin/api/RenameCollectionAPI.java     | 141 +++++++++-------
 .../solr/handler/admin/api/SyncShardAPI.java       | 117 +++++++++----
 .../solr/handler/admin/TestApiFramework.java       |   9 -
 .../solr/handler/admin/TestCollectionAPIs.java     |   3 -
 .../solr/handler/admin/api/ForceLeaderAPITest.java |  53 ++++++
 .../handler/admin/api/ReloadCollectionAPITest.java |  57 +++++++
 .../solr/handler/admin/api/SyncShardAPITest.java   |  53 ++++++
 .../admin/api/V2CollectionAPIMappingTest.java      |  30 ----
 .../handler/admin/api/V2ShardsAPIMappingTest.java  |  25 ---
 .../solr/util/tracing/TestDistributedTracing.java  |   6 +-
 .../pages/collection-management.adoc               |  27 +--
 .../deployment-guide/pages/shard-management.adoc   |   6 +-
 .../solrj/request/beans/ForceLeaderPayload.java    |  25 ---
 .../request/beans/ReloadCollectionPayload.java     |  24 ---
 .../request/beans/RenameCollectionPayload.java     |  29 ----
 .../solrj/request/beans/SyncShardPayload.java      |  23 ---
 21 files changed, 583 insertions(+), 487 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index dce082498f0..7b8e9eb0450 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -133,6 +133,11 @@ Improvements
   changing the path.  The v2 functionality can now be accessed at: `POST /api/collections/cName/shards/sName/replicas {...}`
   (Jason Gerlowski)
 
+* SOLR-16398: Several v2 "command" APIs have been tweaked to be more intuitive. "FORCELEADER" is now available at
+  `POST /api/collections/cName/shards/sName/force-leader`, "RELOAD" is now available at `POST /api/collections/cName/reload`,
+  "SYNCSHARD" is now available at `POST /api/collections/cName/shards/sName/sync-shard`, and "RENAME" is now available at
+  `POST /api/collections/cName/rename`. (Jason Gerlowski)
+
 Optimizations
 ---------------------
 
diff --git a/solr/core/src/java/org/apache/solr/cloud/api/collections/DistributedCollectionConfigSetCommandRunner.java b/solr/core/src/java/org/apache/solr/cloud/api/collections/DistributedCollectionConfigSetCommandRunner.java
index 9c7697caef2..726d13812bf 100644
--- a/solr/core/src/java/org/apache/solr/cloud/api/collections/DistributedCollectionConfigSetCommandRunner.java
+++ b/solr/core/src/java/org/apache/solr/cloud/api/collections/DistributedCollectionConfigSetCommandRunner.java
@@ -275,10 +275,9 @@ public class DistributedCollectionConfigSetCommandRunner {
     // Happens either in the CollectionCommandRunner below or in the catch when the runner would not
     // execute.
     if (!asyncTaskTracker.createNewAsyncJobTracker(asyncId)) {
-      NamedList<Object> resp = new NamedList<>();
-      resp.add("error", "Task with the same requestid already exists. (" + asyncId + ")");
-      resp.add(CoreAdminParams.REQUESTID, asyncId);
-      return new OverseerSolrResponse(resp);
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Task with the same requestid already exists. (" + asyncId + ")");
     }
 
     CollectionCommandRunner commandRunner = new CollectionCommandRunner(message, action, asyncId);
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
index eefe2953275..eef39bee14d 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
@@ -121,15 +121,11 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.stream.Collectors;
 import org.apache.solr.api.AnnotatedApi;
 import org.apache.solr.api.Api;
 import org.apache.solr.api.JerseyResource;
-import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.SolrResponse;
-import org.apache.solr.client.solrj.impl.HttpSolrClient.Builder;
 import org.apache.solr.client.solrj.request.CollectionAdminRequest;
-import org.apache.solr.client.solrj.request.CoreAdminRequest.RequestSyncShard;
 import org.apache.solr.client.solrj.response.RequestStatusState;
 import org.apache.solr.cloud.OverseerSolrResponse;
 import org.apache.solr.cloud.OverseerSolrResponseSerializer;
@@ -137,20 +133,17 @@ import org.apache.solr.cloud.OverseerTaskQueue;
 import org.apache.solr.cloud.OverseerTaskQueue.QueueEvent;
 import org.apache.solr.cloud.ZkController;
 import org.apache.solr.cloud.ZkController.NotInClusterStateException;
-import org.apache.solr.cloud.ZkShardTerms;
 import org.apache.solr.cloud.api.collections.DistributedCollectionConfigSetCommandRunner;
 import org.apache.solr.cloud.api.collections.ReindexCollectionCmd;
 import org.apache.solr.cloud.overseer.SliceMutator;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrException.ErrorCode;
 import org.apache.solr.common.cloud.ClusterProperties;
-import org.apache.solr.common.cloud.ClusterState;
 import org.apache.solr.common.cloud.DocCollection;
 import org.apache.solr.common.cloud.Replica;
 import org.apache.solr.common.cloud.Replica.State;
 import org.apache.solr.common.cloud.Slice;
 import org.apache.solr.common.cloud.SolrZkClient;
-import org.apache.solr.common.cloud.ZkCoreNodeProps;
 import org.apache.solr.common.cloud.ZkNodeProps;
 import org.apache.solr.common.cloud.ZkStateReader;
 import org.apache.solr.common.params.CollectionAdminParams;
@@ -567,8 +560,8 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
     RELOAD_OP(
         RELOAD,
         (req, rsp, h) -> {
-          Map<String, Object> map = copy(req.getParams().required(), null, NAME);
-          return copy(req.getParams(), map);
+          ReloadCollectionAPI.invokeFromV1Params(h.coreContainer, req, rsp);
+          return null;
         }),
 
     RENAME_OP(
@@ -614,32 +607,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
     SYNCSHARD_OP(
         SYNCSHARD,
         (req, rsp, h) -> {
-          String extCollection = req.getParams().required().get("collection");
-          String collection =
-              h.coreContainer
-                  .getZkController()
-                  .getZkStateReader()
-                  .getAliases()
-                  .resolveSimpleAlias(extCollection);
-          String shard = req.getParams().required().get("shard");
-
-          ClusterState clusterState = h.coreContainer.getZkController().getClusterState();
-
-          DocCollection docCollection = clusterState.getCollection(collection);
-          ZkNodeProps leaderProps = docCollection.getLeader(shard);
-          ZkCoreNodeProps nodeProps = new ZkCoreNodeProps(leaderProps);
-
-          try (SolrClient client =
-              new Builder(nodeProps.getBaseUrl())
-                  .withConnectionTimeout(15000, TimeUnit.MILLISECONDS)
-                  .withSocketTimeout(60000, TimeUnit.MILLISECONDS)
-                  .build()) {
-            RequestSyncShard reqSyncShard = new RequestSyncShard();
-            reqSyncShard.setCollection(collection);
-            reqSyncShard.setShard(shard);
-            reqSyncShard.setCoreName(nodeProps.getCoreName());
-            client.request(reqSyncShard);
-          }
+          SyncShardAPI.invokeFromV1Params(h.coreContainer, req, rsp);
           return null;
         }),
 
@@ -755,7 +723,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
     FORCELEADER_OP(
         FORCELEADER,
         (req, rsp, h) -> {
-          forceLeaderElection(req, h);
+          ForceLeaderAPI.invokeFromV1Params(h.coreContainer, req, rsp);
           return null;
         }),
     CREATESHARD_OP(
@@ -1319,96 +1287,6 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
     }
   }
 
-  private static void forceLeaderElection(SolrQueryRequest req, CollectionsHandler handler) {
-    ZkController zkController = handler.coreContainer.getZkController();
-    ClusterState clusterState = zkController.getClusterState();
-    String extCollectionName = req.getParams().required().get(COLLECTION_PROP);
-    String collectionName =
-        zkController.zkStateReader.getAliases().resolveSimpleAlias(extCollectionName);
-    String sliceId = req.getParams().required().get(SHARD_ID_PROP);
-
-    log.info("Force leader invoked, state: {}", clusterState);
-    DocCollection collection = clusterState.getCollection(collectionName);
-    Slice slice = collection.getSlice(sliceId);
-    if (slice == null) {
-      throw new SolrException(
-          ErrorCode.BAD_REQUEST,
-          "No shard with name " + sliceId + " exists for collection " + collectionName);
-    }
-
-    try (ZkShardTerms zkShardTerms =
-        new ZkShardTerms(collectionName, slice.getName(), zkController.getZkClient())) {
-      // if an active replica is the leader, then all is fine already
-      Replica leader = slice.getLeader();
-      if (leader != null && leader.getState() == State.ACTIVE) {
-        throw new SolrException(
-            ErrorCode.SERVER_ERROR,
-            "The shard already has an active leader. Force leader is not applicable. State: "
-                + slice);
-      }
-
-      final Set<String> liveNodes = clusterState.getLiveNodes();
-      List<Replica> liveReplicas =
-          slice.getReplicas().stream()
-              .filter(rep -> liveNodes.contains(rep.getNodeName()))
-              .collect(Collectors.toList());
-      boolean shouldIncreaseReplicaTerms =
-          liveReplicas.stream()
-              .noneMatch(
-                  rep ->
-                      zkShardTerms.registered(rep.getName())
-                          && zkShardTerms.canBecomeLeader(rep.getName()));
-      // we won't increase replica's terms if exist a live replica with term equals to leader
-      if (shouldIncreaseReplicaTerms) {
-        // TODO only increase terms of replicas less out-of-sync
-        liveReplicas.stream()
-            .filter(rep -> zkShardTerms.registered(rep.getName()))
-            // TODO should this all be done at once instead of increasing each replica individually?
-            .forEach(rep -> zkShardTerms.setTermEqualsToLeader(rep.getName()));
-      }
-
-      // Wait till we have an active leader
-      boolean success = false;
-      for (int i = 0; i < 9; i++) {
-        Thread.sleep(5000);
-        clusterState = handler.coreContainer.getZkController().getClusterState();
-        collection = clusterState.getCollection(collectionName);
-        slice = collection.getSlice(sliceId);
-        if (slice.getLeader() != null && slice.getLeader().getState() == State.ACTIVE) {
-          success = true;
-          break;
-        }
-        log.warn(
-            "Force leader attempt {}. Waiting 5 secs for an active leader. State of the slice: {}",
-            (i + 1),
-            slice); // nowarn
-      }
-
-      if (success) {
-        log.info(
-            "Successfully issued FORCELEADER command for collection: {}, shard: {}",
-            collectionName,
-            sliceId);
-      } else {
-        log.info(
-            "Couldn't successfully force leader, collection: {}, shard: {}. Cluster state: {}",
-            collectionName,
-            sliceId,
-            clusterState);
-      }
-    } catch (SolrException e) {
-      throw e;
-    } catch (Exception e) {
-      throw new SolrException(
-          ErrorCode.SERVER_ERROR,
-          "Error executing FORCELEADER operation for collection: "
-              + collectionName
-              + " shard: "
-              + sliceId,
-          e);
-    }
-  }
-
   public static void waitForActiveCollection(
       String collectionName, CoreContainer cc, SolrResponse createCollResponse)
       throws KeeperException, InterruptedException {
@@ -1517,12 +1395,16 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
         DeleteReplicaAPI.class,
         DeleteReplicaPropertyAPI.class,
         DeleteShardAPI.class,
+        ForceLeaderAPI.class,
         InstallShardDataAPI.class,
         ListCollectionsAPI.class,
         ListCollectionBackupsAPI.class,
+        ReloadCollectionAPI.class,
+        RenameCollectionAPI.class,
         ReplaceNodeAPI.class,
         BalanceReplicasAPI.class,
         RestoreCollectionAPI.class,
+        SyncShardAPI.class,
         CollectionPropertyAPI.class,
         DeleteNodeAPI.class,
         ListAliasesAPI.class,
@@ -1536,16 +1418,12 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
   public Collection<Api> getApis() {
     final List<Api> apis = new ArrayList<>();
     apis.addAll(AnnotatedApi.getApis(new SplitShardAPI(this)));
-    apis.addAll(AnnotatedApi.getApis(new SyncShardAPI(this)));
-    apis.addAll(AnnotatedApi.getApis(new ForceLeaderAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new BalanceShardUniqueAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new MigrateDocsAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new ModifyCollectionAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new MoveReplicaAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new RebalanceLeadersAPI(this)));
-    apis.addAll(AnnotatedApi.getApis(new ReloadCollectionAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new CollectionStatusAPI(this)));
-    apis.addAll(AnnotatedApi.getApis(new RenameCollectionAPI(this)));
     return apis;
   }
 
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/ForceLeaderAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/ForceLeaderAPI.java
index c8bf6104770..fd32b52a1f7 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/ForceLeaderAPI.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/ForceLeaderAPI.java
@@ -17,52 +17,166 @@
 
 package org.apache.solr.handler.admin.api;
 
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST;
-import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
-import static org.apache.solr.common.params.CollectionParams.ACTION;
-import static org.apache.solr.common.params.CoreAdminParams.SHARD;
-import static org.apache.solr.handler.ClusterAPI.wrapParams;
+import static org.apache.solr.client.solrj.impl.BinaryResponseParser.BINARY_CONTENT_TYPE_V2;
+import static org.apache.solr.common.cloud.ZkStateReader.COLLECTION_PROP;
+import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
 import static org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
 
-import java.util.HashMap;
-import java.util.Map;
-import org.apache.solr.api.Command;
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.api.PayloadObj;
-import org.apache.solr.client.solrj.request.beans.ForceLeaderPayload;
-import org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.handler.admin.CollectionsHandler;
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.apache.solr.cloud.ZkController;
+import org.apache.solr.cloud.ZkShardTerms;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.cloud.ClusterState;
+import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.cloud.Replica;
+import org.apache.solr.common.cloud.Slice;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SolrJerseyResponse;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * V2 API for triggering a leader election on a particular collection and shard.
  *
- * <p>This API (POST /v2/collections/collectionName/shards/shardName {'force-leader': {}}) is
- * analogous to the v1 /admin/collections?action=FORCELEADER command.
- *
- * @see ForceLeaderPayload
+ * <p>This API (POST /v2/collections/collectionName/shards/shardName/force-leader) is analogous to
+ * the v1 /admin/collections?action=FORCELEADER command.
  */
-@EndPoint(
-    path = {"/c/{collection}/shards/{shard}", "/collections/{collection}/shards/{shard}"},
-    method = POST,
-    permission = COLL_EDIT_PERM)
-public class ForceLeaderAPI {
-  private static final String V2_FORCE_LEADER_CMD = "force-leader";
+@Path("/collections/{collectionName}/shards/{shardName}/force-leader")
+public class ForceLeaderAPI extends AdminAPIBase {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  @Inject
+  public ForceLeaderAPI(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse) {
+    super(coreContainer, solrQueryRequest, solrQueryResponse);
+  }
+
+  @POST
+  @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, BINARY_CONTENT_TYPE_V2})
+  @PermissionName(COLL_EDIT_PERM)
+  public SolrJerseyResponse forceLeader(
+      @PathParam("collectionName") String collectionName,
+      @PathParam("shardName") String shardName) {
+    final SolrJerseyResponse response = instantiateJerseyResponse(SolrJerseyResponse.class);
+    ensureRequiredParameterProvided(COLLECTION_PROP, collectionName);
+    ensureRequiredParameterProvided(SHARD_ID_PROP, shardName);
+    fetchAndValidateZooKeeperAwareCoreContainer();
+    recordCollectionForLogAndTracing(collectionName, solrQueryRequest);
+
+    doForceLeaderElection(collectionName, shardName);
+    return response;
+  }
 
-  private final CollectionsHandler collectionsHandler;
+  public static void invokeFromV1Params(
+      CoreContainer coreContainer, SolrQueryRequest request, SolrQueryResponse response) {
+    final var api = new ForceLeaderAPI(coreContainer, request, response);
+    final var params = request.getParams();
+    params.required().check(COLLECTION_PROP, SHARD_ID_PROP);
 
-  public ForceLeaderAPI(CollectionsHandler collectionsHandler) {
-    this.collectionsHandler = collectionsHandler;
+    V2ApiUtils.squashIntoSolrResponseWithoutHeader(
+        response, api.forceLeader(params.get(COLLECTION_PROP), params.get(SHARD_ID_PROP)));
   }
 
-  @Command(name = V2_FORCE_LEADER_CMD)
-  public void forceLeader(PayloadObj<ForceLeaderPayload> obj) throws Exception {
-    final Map<String, Object> addedV1Params = new HashMap<>();
-    final Map<String, String> pathParams = obj.getRequest().getPathTemplateValues();
-    addedV1Params.put(ACTION, CollectionParams.CollectionAction.FORCELEADER.toLower());
-    addedV1Params.put(COLLECTION, pathParams.get(COLLECTION));
-    addedV1Params.put(SHARD, pathParams.get(SHARD));
+  private void doForceLeaderElection(String extCollectionName, String shardName) {
+    ZkController zkController = coreContainer.getZkController();
+    ClusterState clusterState = zkController.getClusterState();
+    String collectionName =
+        zkController.zkStateReader.getAliases().resolveSimpleAlias(extCollectionName);
+
+    log.info("Force leader invoked, state: {}", clusterState);
+    DocCollection collection = clusterState.getCollection(collectionName);
+    Slice slice = collection.getSlice(shardName);
+    if (slice == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "No shard with name " + shardName + " exists for collection " + collectionName);
+    }
+
+    try (ZkShardTerms zkShardTerms =
+        new ZkShardTerms(collectionName, slice.getName(), zkController.getZkClient())) {
+      // if an active replica is the leader, then all is fine already
+      Replica leader = slice.getLeader();
+      if (leader != null && leader.getState() == Replica.State.ACTIVE) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "The shard already has an active leader. Force leader is not applicable. State: "
+                + slice);
+      }
+
+      final Set<String> liveNodes = clusterState.getLiveNodes();
+      List<Replica> liveReplicas =
+          slice.getReplicas().stream()
+              .filter(rep -> liveNodes.contains(rep.getNodeName()))
+              .collect(Collectors.toList());
+      boolean shouldIncreaseReplicaTerms =
+          liveReplicas.stream()
+              .noneMatch(
+                  rep ->
+                      zkShardTerms.registered(rep.getName())
+                          && zkShardTerms.canBecomeLeader(rep.getName()));
+      // we won't increase replica's terms if exist a live replica with term equals to leader
+      if (shouldIncreaseReplicaTerms) {
+        // TODO only increase terms of replicas less out-of-sync
+        liveReplicas.stream()
+            .filter(rep -> zkShardTerms.registered(rep.getName()))
+            // TODO should this all be done at once instead of increasing each replica individually?
+            .forEach(rep -> zkShardTerms.setTermEqualsToLeader(rep.getName()));
+      }
+
+      // Wait till we have an active leader
+      boolean success = false;
+      for (int i = 0; i < 9; i++) {
+        Thread.sleep(5000);
+        clusterState = coreContainer.getZkController().getClusterState();
+        collection = clusterState.getCollection(collectionName);
+        slice = collection.getSlice(shardName);
+        if (slice.getLeader() != null && slice.getLeader().getState() == Replica.State.ACTIVE) {
+          success = true;
+          break;
+        }
+        log.warn(
+            "Force leader attempt {}. Waiting 5 secs for an active leader. State of the slice: {}",
+            (i + 1),
+            slice); // nowarn
+      }
 
-    collectionsHandler.handleRequestBody(
-        wrapParams(obj.getRequest(), addedV1Params), obj.getResponse());
+      if (success) {
+        log.info(
+            "Successfully issued FORCELEADER command for collection: {}, shard: {}",
+            collectionName,
+            shardName);
+      } else {
+        log.info(
+            "Couldn't successfully force leader, collection: {}, shard: {}. Cluster state: {}",
+            collectionName,
+            shardName,
+            clusterState);
+      }
+    } catch (SolrException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Error executing FORCELEADER operation for collection: "
+              + collectionName
+              + " shard: "
+              + shardName,
+          e);
+    }
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/ReloadCollectionAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/ReloadCollectionAPI.java
index 00146f801fd..009ee727969 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/ReloadCollectionAPI.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/ReloadCollectionAPI.java
@@ -16,50 +16,102 @@
  */
 package org.apache.solr.handler.admin.api;
 
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST;
-import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
-import static org.apache.solr.common.params.CommonParams.ACTION;
+import static org.apache.solr.client.solrj.impl.BinaryResponseParser.BINARY_CONTENT_TYPE_V2;
+import static org.apache.solr.cloud.Overseer.QUEUE_OPERATION;
+import static org.apache.solr.common.cloud.ZkStateReader.COLLECTION_PROP;
+import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
 import static org.apache.solr.common.params.CommonParams.NAME;
-import static org.apache.solr.handler.ClusterAPI.wrapParams;
 import static org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.lang.invoke.MethodHandles;
 import java.util.HashMap;
 import java.util.Map;
-import org.apache.solr.api.Command;
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.api.PayloadObj;
-import org.apache.solr.client.solrj.request.beans.ReloadCollectionPayload;
+import javax.inject.Inject;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.apache.solr.common.cloud.ZkNodeProps;
 import org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.handler.admin.CollectionsHandler;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.JacksonReflectMapWriter;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SubResponseAccumulatingJerseyResponse;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * V2 API for reloading collections.
  *
- * <p>The new API (POST /v2/collections/collectionName {'reload': {...}}) is analogous to the v1
+ * <p>The new API (POST /v2/collections/collectionName/reload {...}) is analogous to the v1
  * /admin/collections?action=RELOAD command.
- *
- * @see ReloadCollectionPayload
  */
-@EndPoint(
-    path = {"/c/{collection}", "/collections/{collection}"},
-    method = POST,
-    permission = COLL_EDIT_PERM) // TODO Does this permission make sense for reload?
-public class ReloadCollectionAPI {
-  private static final String V2_RELOAD_COLLECTION_CMD = "reload";
+@Path("/collections/{collectionName}/reload")
+public class ReloadCollectionAPI extends AdminAPIBase {
 
-  private final CollectionsHandler collectionsHandler;
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
-  public ReloadCollectionAPI(CollectionsHandler collectionsHandler) {
-    this.collectionsHandler = collectionsHandler;
+  @Inject
+  public ReloadCollectionAPI(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse) {
+    super(coreContainer, solrQueryRequest, solrQueryResponse);
   }
 
-  @Command(name = V2_RELOAD_COLLECTION_CMD)
-  public void reloadCollection(PayloadObj<ReloadCollectionPayload> obj) throws Exception {
-    final ReloadCollectionPayload v2Body = obj.get();
-    final Map<String, Object> v1Params = v2Body.toMap(new HashMap<>());
-    v1Params.put(ACTION, CollectionParams.CollectionAction.RELOAD.toLower());
-    v1Params.put(NAME, obj.getRequest().getPathTemplateValues().get(COLLECTION));
+  @POST
+  @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, BINARY_CONTENT_TYPE_V2})
+  @PermissionName(COLL_EDIT_PERM)
+  public SubResponseAccumulatingJerseyResponse reloadCollection(
+      @PathParam("collectionName") String collectionName, ReloadCollectionRequestBody requestBody)
+      throws Exception {
+    final var response = instantiateJerseyResponse(SubResponseAccumulatingJerseyResponse.class);
+    ensureRequiredParameterProvided(COLLECTION_PROP, collectionName);
+    fetchAndValidateZooKeeperAwareCoreContainer();
+    recordCollectionForLogAndTracing(collectionName, solrQueryRequest);
+
+    final ZkNodeProps remoteMessage = createRemoteMessage(collectionName, requestBody);
+    submitRemoteMessageAndHandleResponse(
+        response,
+        CollectionParams.CollectionAction.RELOAD,
+        remoteMessage,
+        requestBody != null ? requestBody.asyncId : null);
+    return response;
+  }
+
+  public static ZkNodeProps createRemoteMessage(
+      String collectionName, ReloadCollectionRequestBody requestBody) {
+    final Map<String, Object> remoteMessage = new HashMap<>();
+    remoteMessage.put(QUEUE_OPERATION, CollectionParams.CollectionAction.RELOAD.toLower());
+    remoteMessage.put(NAME, collectionName);
+    if (requestBody != null) {
+      insertIfNotNull(remoteMessage, ASYNC, requestBody.asyncId);
+    }
+
+    return new ZkNodeProps(remoteMessage);
+  }
+
+  public static void invokeFromV1Params(
+      CoreContainer coreContainer, SolrQueryRequest request, SolrQueryResponse response)
+      throws Exception {
+    final var api = new ReloadCollectionAPI(coreContainer, request, response);
+    final var params = request.getParams();
+    params.required().check(NAME);
+    final var requestBody = new ReloadCollectionRequestBody();
+    requestBody.asyncId = params.get(ASYNC); // Note, 'async' may or may not have been provided.
+
+    V2ApiUtils.squashIntoSolrResponseWithoutHeader(
+        response, api.reloadCollection(params.get(NAME), requestBody));
+  }
 
-    collectionsHandler.handleRequestBody(wrapParams(obj.getRequest(), v1Params), obj.getResponse());
+  // TODO Is it worth having this in a request body, or should we just make it a query param?
+  public static class ReloadCollectionRequestBody implements JacksonReflectMapWriter {
+    @JsonProperty(ASYNC)
+    public String asyncId;
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/RenameCollectionAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/RenameCollectionAPI.java
index de6e0106701..c63c518426b 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/RenameCollectionAPI.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/RenameCollectionAPI.java
@@ -16,87 +16,110 @@
  */
 package org.apache.solr.handler.admin.api;
 
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST;
+import static org.apache.solr.client.solrj.impl.BinaryResponseParser.BINARY_CONTENT_TYPE_V2;
+import static org.apache.solr.cloud.Overseer.QUEUE_OPERATION;
+import static org.apache.solr.common.cloud.ZkStateReader.COLLECTION_PROP;
 import static org.apache.solr.common.params.CollectionAdminParams.FOLLOW_ALIASES;
 import static org.apache.solr.common.params.CollectionAdminParams.TARGET;
 import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
-import static org.apache.solr.common.params.CommonParams.ACTION;
 import static org.apache.solr.common.params.CommonParams.NAME;
-import static org.apache.solr.handler.ClusterAPI.wrapParams;
 import static org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
 
-import com.fasterxml.jackson.databind.DeserializationFeature;
-import com.fasterxml.jackson.databind.MapperFeature;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import java.io.IOException;
-import java.util.Locale;
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.client.solrj.request.beans.RenameCollectionPayload;
-import org.apache.solr.common.params.CollectionAdminParams;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.HashMap;
+import java.util.Map;
+import javax.inject.Inject;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.cloud.ZkNodeProps;
 import org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.common.util.ContentStream;
-import org.apache.solr.handler.admin.CollectionsHandler;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.JacksonReflectMapWriter;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SubResponseAccumulatingJerseyResponse;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.response.SolrQueryResponse;
-import org.apache.solr.util.SolrJacksonAnnotationInspector;
 
 /**
  * V2 API for "renaming" an existing collection
  *
  * <p>This API is analogous to the v1 /admin/collections?action=RENAME command.
  */
-public class RenameCollectionAPI {
+@Path("/collections/{collectionName}/rename")
+public class RenameCollectionAPI extends AdminAPIBase {
 
-  private final CollectionsHandler collectionsHandler;
-  private static final ObjectMapper REQUEST_BODY_PARSER =
-      SolrJacksonAnnotationInspector.createObjectMapper()
-          .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
-          .disable(MapperFeature.AUTO_DETECT_FIELDS);
+  @Inject
+  public RenameCollectionAPI(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse) {
+    super(coreContainer, solrQueryRequest, solrQueryResponse);
+  }
+
+  @POST
+  @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, BINARY_CONTENT_TYPE_V2})
+  @PermissionName(COLL_EDIT_PERM)
+  public SubResponseAccumulatingJerseyResponse renameCollection(
+      @PathParam("collectionName") String collectionName, RenameCollectionRequestBody requestBody)
+      throws Exception {
+    final var response = instantiateJerseyResponse(SubResponseAccumulatingJerseyResponse.class);
+    if (requestBody == null) {
+      throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Missing required request body");
+    }
+    ensureRequiredParameterProvided(COLLECTION_PROP, collectionName);
+    ensureRequiredParameterProvided("to", requestBody.to);
+    fetchAndValidateZooKeeperAwareCoreContainer();
+    recordCollectionForLogAndTracing(collectionName, solrQueryRequest);
 
-  public RenameCollectionAPI(CollectionsHandler collectionsHandler) {
-    this.collectionsHandler = collectionsHandler;
+    final ZkNodeProps remoteMessage = createRemoteMessage(collectionName, requestBody);
+    submitRemoteMessageAndHandleResponse(
+        response,
+        CollectionParams.CollectionAction.RENAME,
+        remoteMessage,
+        requestBody != null ? requestBody.asyncId : null);
+    return response;
   }
 
-  @EndPoint(
-      path = {"/collections/{collection}/rename"},
-      method = POST,
-      permission = COLL_EDIT_PERM)
-  public void renameCollection(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
-    final RenameCollectionPayload v2Body = parseRenameParamsFromRequestBody(req);
+  public static ZkNodeProps createRemoteMessage(
+      String collectionName, RenameCollectionRequestBody requestBody) {
+    final Map<String, Object> remoteMessage = new HashMap<>();
+    remoteMessage.put(QUEUE_OPERATION, CollectionParams.CollectionAction.RENAME.toLower());
+    remoteMessage.put(NAME, collectionName);
+    remoteMessage.put(TARGET, requestBody.to);
+    insertIfNotNull(remoteMessage, FOLLOW_ALIASES, requestBody.followAliases);
+    insertIfNotNull(remoteMessage, ASYNC, requestBody.asyncId);
 
-    req =
-        wrapParams(
-            req,
-            ACTION,
-            CollectionParams.CollectionAction.RENAME.name().toLowerCase(Locale.ROOT),
-            NAME,
-            req.getPathTemplateValues().get(CollectionAdminParams.COLLECTION),
-            TARGET,
-            v2Body.to,
-            ASYNC,
-            v2Body.async,
-            FOLLOW_ALIASES,
-            v2Body.followAliases);
-    collectionsHandler.handleRequestBody(req, rsp);
+    return new ZkNodeProps(remoteMessage);
   }
 
-  // TODO This is a bit hacky, but it's not worth investing in the request-body parsing code much
-  // here, as it's
-  //  something that's already somewhat built-in when this eventually moves to JAX-RS
-  private RenameCollectionPayload parseRenameParamsFromRequestBody(
-      SolrQueryRequest solrQueryRequest) throws IOException {
-    Iterable<ContentStream> contentStreams = solrQueryRequest.getContentStreams();
-    ContentStream cs = null;
-    if (contentStreams != null) {
-      cs = contentStreams.iterator().next();
-    }
-    if (cs == null) {
-      // An empty request-body is invalid (the 'to' field is required at a minimum), but we'll lean
-      // on the input-validation in CollectionsHandler to
-      // catch this, rather than duplicating the check for that here
-      return new RenameCollectionPayload();
-    }
+  public static void invokeFromV1Params(
+      CoreContainer coreContainer, SolrQueryRequest request, SolrQueryResponse response)
+      throws Exception {
+    final var api = new RenameCollectionAPI(coreContainer, request, response);
+    final var params = request.getParams();
+    params.required().check(COLLECTION_PROP, TARGET);
+    final var requestBody = new RenameCollectionRequestBody();
+    requestBody.to = params.get(TARGET);
+    // Optional parameters
+    requestBody.asyncId = params.get(ASYNC);
+    requestBody.followAliases = params.getBool(FOLLOW_ALIASES);
+
+    V2ApiUtils.squashIntoSolrResponseWithoutHeader(
+        response, api.renameCollection(params.get(COLLECTION_PROP), requestBody));
+  }
+
+  public static class RenameCollectionRequestBody implements JacksonReflectMapWriter {
+    @JsonProperty(required = true)
+    public String to;
+
+    @JsonProperty(ASYNC)
+    public String asyncId;
 
-    return REQUEST_BODY_PARSER.readValue(cs.getStream(), RenameCollectionPayload.class);
+    @JsonProperty public Boolean followAliases;
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/SyncShardAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/SyncShardAPI.java
index 0819b09ad51..07488e3c5ed 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/SyncShardAPI.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/SyncShardAPI.java
@@ -17,52 +17,99 @@
 
 package org.apache.solr.handler.admin.api;
 
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST;
-import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
-import static org.apache.solr.common.params.CollectionParams.ACTION;
-import static org.apache.solr.common.params.CoreAdminParams.SHARD;
-import static org.apache.solr.handler.ClusterAPI.wrapParams;
+import static org.apache.solr.client.solrj.impl.BinaryResponseParser.BINARY_CONTENT_TYPE_V2;
+import static org.apache.solr.common.cloud.ZkStateReader.COLLECTION_PROP;
+import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
 import static org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
 
-import java.util.HashMap;
-import java.util.Map;
-import org.apache.solr.api.Command;
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.api.PayloadObj;
-import org.apache.solr.client.solrj.request.beans.SyncShardPayload;
-import org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.handler.admin.CollectionsHandler;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import javax.inject.Inject;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.impl.HttpSolrClient;
+import org.apache.solr.client.solrj.request.CoreAdminRequest;
+import org.apache.solr.common.cloud.ClusterState;
+import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.cloud.ZkCoreNodeProps;
+import org.apache.solr.common.cloud.ZkNodeProps;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SolrJerseyResponse;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
 
 /**
  * V2 API for triggering a shard-sync operation within a particular collection and shard.
  *
- * <p>This API (POST /v2/collections/collectionName/shards/shardName {'sync-shard': {}}) is
- * analogous to the v1 /admin/collections?action=SYNCSHARD command.
- *
- * @see SyncShardPayload
+ * <p>This API (POST /v2/collections/cName/shards/sName/sync {...}) is analogous to the v1
+ * /admin/collections?action=SYNCSHARD command.
  */
-@EndPoint(
-    path = {"/c/{collection}/shards/{shard}", "/collections/{collection}/shards/{shard}"},
-    method = POST,
-    permission = COLL_EDIT_PERM)
-public class SyncShardAPI {
-  private static final String V2_SYNC_SHARD_CMD = "sync-shard";
+@Path("/collections/{collectionName}/shards/{shardName}/sync")
+public class SyncShardAPI extends AdminAPIBase {
+
+  @Inject
+  public SyncShardAPI(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse) {
+    super(coreContainer, solrQueryRequest, solrQueryResponse);
+  }
+
+  @POST
+  @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, BINARY_CONTENT_TYPE_V2})
+  @PermissionName(COLL_EDIT_PERM)
+  public SolrJerseyResponse syncShard(
+      @PathParam("collectionName") String collectionName, @PathParam("shardName") String shardName)
+      throws Exception {
+    final SolrJerseyResponse response = instantiateJerseyResponse(SolrJerseyResponse.class);
+    ensureRequiredParameterProvided(COLLECTION_PROP, collectionName);
+    ensureRequiredParameterProvided(SHARD_ID_PROP, shardName);
+    fetchAndValidateZooKeeperAwareCoreContainer();
+    recordCollectionForLogAndTracing(collectionName, solrQueryRequest);
+
+    doSyncShard(collectionName, shardName);
+
+    return response;
+  }
+
+  private void doSyncShard(String extCollectionName, String shardName)
+      throws IOException, SolrServerException {
+    String collection = coreContainer.getAliases().resolveSimpleAlias(extCollectionName);
+
+    ClusterState clusterState = coreContainer.getZkController().getClusterState();
 
-  private final CollectionsHandler collectionsHandler;
+    DocCollection docCollection = clusterState.getCollection(collection);
+    ZkNodeProps leaderProps = docCollection.getLeader(shardName);
+    ZkCoreNodeProps nodeProps = new ZkCoreNodeProps(leaderProps);
 
-  public SyncShardAPI(CollectionsHandler collectionsHandler) {
-    this.collectionsHandler = collectionsHandler;
+    try (SolrClient client =
+        new HttpSolrClient.Builder(nodeProps.getBaseUrl())
+            .withConnectionTimeout(15000, TimeUnit.MILLISECONDS)
+            .withSocketTimeout(60000, TimeUnit.MILLISECONDS)
+            .build()) {
+      CoreAdminRequest.RequestSyncShard reqSyncShard = new CoreAdminRequest.RequestSyncShard();
+      reqSyncShard.setCollection(collection);
+      reqSyncShard.setShard(shardName);
+      reqSyncShard.setCoreName(nodeProps.getCoreName());
+      client.request(reqSyncShard);
+    }
   }
 
-  @Command(name = V2_SYNC_SHARD_CMD)
-  public void syncShard(PayloadObj<SyncShardPayload> obj) throws Exception {
-    final Map<String, Object> addedV1Params = new HashMap<>();
-    final Map<String, String> pathParams = obj.getRequest().getPathTemplateValues();
-    addedV1Params.put(ACTION, CollectionParams.CollectionAction.SYNCSHARD.toLower());
-    addedV1Params.put(COLLECTION, pathParams.get(COLLECTION));
-    addedV1Params.put(SHARD, pathParams.get(SHARD));
+  public static void invokeFromV1Params(
+      CoreContainer coreContainer, SolrQueryRequest request, SolrQueryResponse response)
+      throws Exception {
+    final var api = new SyncShardAPI(coreContainer, request, response);
+    final var params = request.getParams();
+    params.required().check(COLLECTION_PROP, SHARD_ID_PROP);
 
-    collectionsHandler.handleRequestBody(
-        wrapParams(obj.getRequest(), addedV1Params), obj.getResponse());
+    V2ApiUtils.squashIntoSolrResponseWithoutHeader(
+        response, api.syncShard(params.get(COLLECTION_PROP), params.get(SHARD_ID_PROP)));
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java b/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
index e416d35bf5b..deefc0e4df3 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
@@ -102,15 +102,6 @@ public class TestApiFramework extends SolrTestCaseJ4 {
         V2HttpCall.getApiInfo(containerHandlers, "/collections/hello/shards", "POST", null, parts);
     assertConditions(api.getSpec(), Map.of("/methods[0]", "POST", "/commands/split", NOT_NULL));
 
-    parts = new HashMap<>();
-    api =
-        V2HttpCall.getApiInfo(
-            containerHandlers, "/collections/hello/shards/shard1", "POST", null, parts);
-    assertConditions(
-        api.getSpec(), Map.of("/methods[0]", "POST", "/commands/force-leader", NOT_NULL));
-    assertEquals("hello", parts.get("collection"));
-    assertEquals("shard1", parts.get("shard"));
-
     parts = new HashMap<>();
     api = V2HttpCall.getApiInfo(containerHandlers, "/collections/hello", "POST", null, parts);
     assertConditions(api.getSpec(), Map.of("/methods[0]", "POST", "/commands/modify", NOT_NULL));
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java b/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
index fa11d4aa4cb..4aa4ff96eb2 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
@@ -88,9 +88,6 @@ public class TestCollectionAPIs extends SolrTestCaseJ4 {
       apiBag.registerObject(clusterAPI.commands);
     }
 
-    compareOutput(
-        apiBag, "/collections/collName", POST, "{reload:{}}", "{name:collName, operation :reload}");
-
     compareOutput(
         apiBag,
         "/collections/collName/shards",
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/ForceLeaderAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/ForceLeaderAPITest.java
new file mode 100644
index 00000000000..09f97a4757f
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/ForceLeaderAPITest.java
@@ -0,0 +1,53 @@
+/*
+ * 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 org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.junit.Test;
+
+/** Unit tests for {@link ForceLeaderAPI} */
+public class ForceLeaderAPITest extends SolrTestCaseJ4 {
+  @Test
+  public void testReportsErrorIfCollectionNameMissing() {
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new ForceLeaderAPI(null, null, null);
+              api.forceLeader(null, "someShard");
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: collection", thrown.getMessage());
+  }
+
+  @Test
+  public void testReportsErrorIfShardNameMissing() {
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new ForceLeaderAPI(null, null, null);
+              api.forceLeader("someCollection", null);
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: shard", thrown.getMessage());
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/ReloadCollectionAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/ReloadCollectionAPITest.java
new file mode 100644
index 00000000000..e7534dc0c1a
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/ReloadCollectionAPITest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.cloud.Overseer.QUEUE_OPERATION;
+import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
+import static org.apache.solr.common.params.CoreAdminParams.NAME;
+
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.junit.Test;
+
+/** Unit tests for {@link ReloadCollectionAPI} */
+public class ReloadCollectionAPITest extends SolrTestCaseJ4 {
+  @Test
+  public void testReportsErrorIfCollectionNameMissing() {
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new ReloadCollectionAPI(null, null, null);
+              api.reloadCollection(null, new ReloadCollectionAPI.ReloadCollectionRequestBody());
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: collection", thrown.getMessage());
+  }
+
+  // TODO message creation
+  @Test
+  public void testCreateRemoteMessageAllProperties() {
+    final var requestBody = new ReloadCollectionAPI.ReloadCollectionRequestBody();
+    requestBody.asyncId = "someAsyncId";
+    final var remoteMessage =
+        ReloadCollectionAPI.createRemoteMessage("someCollName", requestBody).getProperties();
+
+    assertEquals(3, remoteMessage.size());
+    assertEquals("reload", remoteMessage.get(QUEUE_OPERATION));
+    assertEquals("someCollName", remoteMessage.get(NAME));
+    assertEquals("someAsyncId", remoteMessage.get(ASYNC));
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SyncShardAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SyncShardAPITest.java
new file mode 100644
index 00000000000..25ca5bb9cd1
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SyncShardAPITest.java
@@ -0,0 +1,53 @@
+/*
+ * 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 org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.junit.Test;
+
+/** Unit tests for {@link SyncShardAPI} */
+public class SyncShardAPITest extends SolrTestCaseJ4 {
+  @Test
+  public void testReportsErrorIfCollectionNameMissing() {
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new SyncShardAPI(null, null, null);
+              api.syncShard(null, "someShard");
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: collection", thrown.getMessage());
+  }
+
+  @Test
+  public void testReportsErrorIfShardNameMissing() {
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new SyncShardAPI(null, null, null);
+              api.syncShard("someCollection", null);
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: shard", thrown.getMessage());
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/V2CollectionAPIMappingTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/V2CollectionAPIMappingTest.java
index fa943c80740..503be4fa6f0 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/api/V2CollectionAPIMappingTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/V2CollectionAPIMappingTest.java
@@ -19,10 +19,8 @@ package org.apache.solr.handler.admin.api;
 
 import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
 import static org.apache.solr.common.params.CollectionAdminParams.COLL_CONF;
-import static org.apache.solr.common.params.CollectionAdminParams.TARGET;
 import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
 import static org.apache.solr.common.params.CommonParams.ACTION;
-import static org.apache.solr.common.params.CommonParams.NAME;
 import static org.apache.solr.common.params.CoreAdminParams.SHARD;
 
 import java.util.Map;
@@ -59,9 +57,7 @@ public class V2CollectionAPIMappingTest extends V2ApiMappingTest<CollectionsHand
     apiBag.registerObject(new ModifyCollectionAPI(collectionsHandler));
     apiBag.registerObject(new MoveReplicaAPI(collectionsHandler));
     apiBag.registerObject(new RebalanceLeadersAPI(collectionsHandler));
-    apiBag.registerObject(new ReloadCollectionAPI(collectionsHandler));
     apiBag.registerObject(new CollectionStatusAPI(collectionsHandler));
-    apiBag.registerObject(new RenameCollectionAPI(collectionsHandler));
   }
 
   @Override
@@ -85,21 +81,6 @@ public class V2CollectionAPIMappingTest extends V2ApiMappingTest<CollectionsHand
     assertEquals("shard2", v1Params.get(SHARD));
   }
 
-  @Test
-  public void testRenameCollectionAllParams() throws Exception {
-    final SolrParams v1Params =
-        captureConvertedV1Params(
-            "/collections/collName/rename",
-            "POST",
-            "{\"to\": \"targetColl\", \"async\": \"requestTrackingId\", \"followAliases\": true}");
-
-    assertEquals("rename", v1Params.get(ACTION));
-    assertEquals("collName", v1Params.get(NAME));
-    assertEquals("targetColl", v1Params.get(TARGET));
-    assertEquals("requestTrackingId", v1Params.get(ASYNC));
-    assertEquals(true, v1Params.getPrimitiveBool("followAliases"));
-  }
-
   @Test
   public void testModifyCollectionAllProperties() throws Exception {
     final SolrParams v1Params =
@@ -128,17 +109,6 @@ public class V2CollectionAPIMappingTest extends V2ApiMappingTest<CollectionsHand
     assertEquals(456, v1Params.getPrimitiveInt("property.baz"));
   }
 
-  @Test
-  public void testReloadCollectionAllProperties() throws Exception {
-    final SolrParams v1Params =
-        captureConvertedV1Params(
-            "/collections/collName", "POST", "{ 'reload': {'async': 'requestTrackingId'}}");
-
-    assertEquals(CollectionParams.CollectionAction.RELOAD.lowerName, v1Params.get(ACTION));
-    assertEquals("collName", v1Params.get(NAME));
-    assertEquals("requestTrackingId", v1Params.get(ASYNC));
-  }
-
   @Test
   public void testMoveReplicaAllProperties() throws Exception {
     final SolrParams v1Params =
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java
index 0d584973a7a..9731922de46 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java
@@ -29,7 +29,6 @@ import static org.apache.solr.common.params.CommonAdminParams.SPLIT_METHOD;
 import static org.apache.solr.common.params.CommonAdminParams.WAIT_FOR_FINAL_STATE;
 import static org.apache.solr.common.params.CommonParams.ACTION;
 import static org.apache.solr.common.params.CommonParams.TIMING;
-import static org.apache.solr.common.params.CoreAdminParams.SHARD;
 
 import org.apache.solr.common.params.CollectionParams;
 import org.apache.solr.common.params.CoreAdminParams;
@@ -53,8 +52,6 @@ public class V2ShardsAPIMappingTest extends V2ApiMappingTest<CollectionsHandler>
   public void populateApiBag() {
     final CollectionsHandler collectionsHandler = getRequestHandler();
     apiBag.registerObject(new SplitShardAPI(collectionsHandler));
-    apiBag.registerObject(new SyncShardAPI(collectionsHandler));
-    apiBag.registerObject(new ForceLeaderAPI(collectionsHandler));
   }
 
   @Override
@@ -67,28 +64,6 @@ public class V2ShardsAPIMappingTest extends V2ApiMappingTest<CollectionsHandler>
     return false;
   }
 
-  @Test
-  public void testForceLeaderAllProperties() throws Exception {
-    final SolrParams v1Params =
-        captureConvertedV1Params(
-            "/collections/collName/shards/shardName", "POST", "{ 'force-leader': {}}");
-
-    assertEquals(CollectionParams.CollectionAction.FORCELEADER.lowerName, v1Params.get(ACTION));
-    assertEquals("collName", v1Params.get(COLLECTION));
-    assertEquals("shardName", v1Params.get(SHARD));
-  }
-
-  @Test
-  public void testSyncShardAllProperties() throws Exception {
-    final SolrParams v1Params =
-        captureConvertedV1Params(
-            "/collections/collName/shards/shardName", "POST", "{ 'sync-shard': {}}");
-
-    assertEquals(CollectionParams.CollectionAction.SYNCSHARD.lowerName, v1Params.get(ACTION));
-    assertEquals("collName", v1Params.get(COLLECTION));
-    assertEquals("shardName", v1Params.get(SHARD));
-  }
-
   @Test
   public void testSplitShardAllProperties() throws Exception {
     final SolrParams v1Params =
diff --git a/solr/core/src/test/org/apache/solr/util/tracing/TestDistributedTracing.java b/solr/core/src/test/org/apache/solr/util/tracing/TestDistributedTracing.java
index cbfd4a64df9..b28f15e18bd 100644
--- a/solr/core/src/test/org/apache/solr/util/tracing/TestDistributedTracing.java
+++ b/solr/core/src/test/org/apache/solr/util/tracing/TestDistributedTracing.java
@@ -134,13 +134,13 @@ public class TestDistributedTracing extends SolrCloudTestCase {
     CloudSolrClient cloudClient = cluster.getSolrClient();
     List<MockSpan> finishedSpans;
 
-    new V2Request.Builder("/c/" + COLLECTION)
+    new V2Request.Builder("/collections/" + COLLECTION + "/reload")
         .withMethod(SolrRequest.METHOD.POST)
-        .withPayload("{\n" + " \"reload\" : {}\n" + "}")
+        .withPayload("{}")
         .build()
         .process(cloudClient);
     finishedSpans = getAndClearSpans();
-    assertEquals("reload:/c/{collection}", finishedSpans.get(0).operationName());
+    assertEquals("post:/collections/{collection}/reload", finishedSpans.get(0).operationName());
     assertDbInstanceColl(finishedSpans.get(0));
 
     new V2Request.Builder("/c/" + COLLECTION + "/update/json")
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/collection-management.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/collection-management.adoc
index 2c8de19cbc4..61e987509c6 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/collection-management.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/collection-management.adoc
@@ -321,28 +321,14 @@ http://localhost:8983/solr/admin/collections?action=RELOAD&name=techproducts_v2
 ====
 [.tab-label]*V2 API*
 
-With the v2 API, the `reload` command is provided as part of the JSON data that contains the required parameters:
+With the v2 API, the `reload` command is provided as a part of URL path. The request body is optional if the optional `async` parameter is omitted:
 
 [source,bash]
 ----
-curl -X POST http://localhost:8983/api/collections/techproducts_v2 -H 'Content-Type: application/json' -d '
-  {
-    "reload": {}
-  }
-'
-----
-
-Additional parameters can be passed in via the `reload` key:
-
-[source,bash]
-----
-curl -X POST http://localhost:8983/api/collections/techproducts_v2 -H 'Content-Type: application/json' -d '
+curl -X POST http://localhost:8983/api/collections/techproducts_v2/reload -H 'Content-Type: application/json' -d '
   {
-    "reload": {
-      "async": "reload1"
-    }
+    "async": "someAsyncId"
   }
-'
 ----
 ====
 --
@@ -357,7 +343,8 @@ curl -X POST http://localhost:8983/api/collections/techproducts_v2 -H 'Content-T
 |===
 +
 The name of the collection to reload.
-This parameter is required by the V1 API.
+This parameter is required.
+It appears as a query-parameter on v1 requests, and in the URL path of v2 requests.
 
 `async`::
 +
@@ -542,7 +529,7 @@ Aliases that refer to more than 1 collection are not supported.
 
 [source,bash]
 ----
-http://localhost:8983/solr/admin/collections?action=RENAME&name=techproducts_v2&target=renamedCollection
+http://localhost:8983/solr/admin/collections?action=RENAME&name=techproducts_v2&target=newName
 ----
 ====
 
@@ -554,7 +541,7 @@ http://localhost:8983/solr/admin/collections?action=RENAME&name=techproducts_v2&
 ----
 curl -X POST http://localhost:8983/api/collections/techproducts/rename -H 'Content-Type: application/json' -d '
   {
-    "to": "new_name"
+    "to": "newName"
   }
 '
 ----
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/shard-management.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/shard-management.adoc
index c8548b573b9..672ffa199f6 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/shard-management.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/shard-management.adoc
@@ -591,11 +591,7 @@ http://localhost:8983/solr/admin/collections?action=FORCELEADER&collection=techp
 
 [source,bash]
 ----
-curl -X POST http://localhost:8983/api/collections/techproducts/shards/shard1 -H 'Content-Type: application/json' -d '
-  {
-    "force-leader":{}
-  }
-'
+curl -X POST http://localhost:8983/api/collections/techproducts/shards/shard1/force-leader
 ----
 *Output*
 
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/ForceLeaderPayload.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/ForceLeaderPayload.java
deleted file mode 100644
index fb23dd95c50..00000000000
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/ForceLeaderPayload.java
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * 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.client.solrj.request.beans;
-
-import org.apache.solr.common.util.ReflectMapWriter;
-
-/**
- * Empty payload for the POST /v2/collections/collName/shards/shardName {"force-leader": {}} API.
- */
-public class ForceLeaderPayload implements ReflectMapWriter {}
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/ReloadCollectionPayload.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/ReloadCollectionPayload.java
deleted file mode 100644
index 91331d44008..00000000000
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/ReloadCollectionPayload.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * 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.client.solrj.request.beans;
-
-import org.apache.solr.common.annotation.JsonProperty;
-import org.apache.solr.common.util.ReflectMapWriter;
-
-public class ReloadCollectionPayload implements ReflectMapWriter {
-  @JsonProperty public String async;
-}
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/RenameCollectionPayload.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/RenameCollectionPayload.java
deleted file mode 100644
index c85fd42daee..00000000000
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/RenameCollectionPayload.java
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * 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.client.solrj.request.beans;
-
-import org.apache.solr.common.annotation.JsonProperty;
-import org.apache.solr.common.util.ReflectMapWriter;
-
-public class RenameCollectionPayload implements ReflectMapWriter {
-  @JsonProperty public String async;
-
-  @JsonProperty public Boolean followAliases;
-
-  @JsonProperty(required = true)
-  public String to;
-}
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/SyncShardPayload.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/SyncShardPayload.java
deleted file mode 100644
index b6636e65b6e..00000000000
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/SyncShardPayload.java
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * 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.client.solrj.request.beans;
-
-import org.apache.solr.common.util.ReflectMapWriter;
-
-/** Empty payload for the /v2/collections/collName/shards/shardName {"sync-shard": {}} API. */
-public class SyncShardPayload implements ReflectMapWriter {}


[solr] 01/03: SOLR-16392: Tweak v2 ADDREPLICA to be more REST-ful

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 324731e08698c6b0a0d1ec0a94fded7e666ab1f3
Author: Jason Gerlowski <ge...@apache.org>
AuthorDate: Mon Jun 5 12:44:46 2023 -0400

    SOLR-16392: Tweak v2 ADDREPLICA to be more REST-ful
    
    Following these changes, the v2 API now appears as:
    
      `POST /api/collections/cName/shards/sName/replicas {...}`
    
    Although not shown above, the request body has no 'add-replica' command
    specifier.  These changes bring v2 ADDREPLICA more into line with the
    REST-ful design we are aiming for going forward.
    
    This commit also converts the API to the new JAX-RS framework.
---
 solr/CHANGES.txt                                   |   4 +
 .../solr/handler/admin/CollectionsHandler.java     |  41 ++---
 .../solr/handler/admin/api/AddReplicaAPI.java      |  72 --------
 .../solr/handler/admin/api/CreateReplicaAPI.java   | 202 +++++++++++++++++++++
 .../org/apache/solr/cloud/TestPullReplica.java     |   8 +-
 .../org/apache/solr/cloud/TestTlogReplica.java     |  13 +-
 .../solr/handler/admin/TestApiFramework.java       |   7 +-
 .../solr/handler/admin/TestCollectionAPIs.java     |  29 ---
 .../handler/admin/api/CreateReplicaAPITest.java    | 182 +++++++++++++++++++
 .../handler/admin/api/V2ShardsAPIMappingTest.java  |  47 -----
 .../deployment-guide/pages/replica-management.adoc |  20 +-
 .../solrj/request/beans/AddReplicaPayload.java     |  56 ------
 12 files changed, 424 insertions(+), 257 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 2abdb198882..dce082498f0 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -129,6 +129,10 @@ Improvements
   then uses the weights to decide the optimal strategy for placing new replicas or balancing existing replicas.
   (Houston Putman, Tomás Fernández Löbbe, Jason Gerlowski, Radu Gheorghe)
 
+* SOLR-16392: The v2 "add-replica" API has been tweaked to be more intuitive, by removing the top-level command specifier and
+  changing the path.  The v2 functionality can now be accessed at: `POST /api/collections/cName/shards/sName/replicas {...}`
+  (Jason Gerlowski)
+
 Optimizations
 ---------------------
 
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
index a83895e0e36..eefe2953275 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
@@ -36,7 +36,6 @@ import static org.apache.solr.common.cloud.ZkStateReader.PROPERTY_VALUE_PROP;
 import static org.apache.solr.common.cloud.ZkStateReader.PULL_REPLICAS;
 import static org.apache.solr.common.cloud.ZkStateReader.REPLICATION_FACTOR;
 import static org.apache.solr.common.cloud.ZkStateReader.REPLICA_PROP;
-import static org.apache.solr.common.cloud.ZkStateReader.REPLICA_TYPE;
 import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
 import static org.apache.solr.common.cloud.ZkStateReader.TLOG_REPLICAS;
 import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
@@ -46,7 +45,6 @@ import static org.apache.solr.common.params.CollectionAdminParams.PROPERTY_NAME;
 import static org.apache.solr.common.params.CollectionAdminParams.PROPERTY_PREFIX;
 import static org.apache.solr.common.params.CollectionAdminParams.PROPERTY_VALUE;
 import static org.apache.solr.common.params.CollectionAdminParams.SHARD;
-import static org.apache.solr.common.params.CollectionAdminParams.SKIP_NODE_ASSIGNMENT;
 import static org.apache.solr.common.params.CollectionParams.CollectionAction.ADDREPLICA;
 import static org.apache.solr.common.params.CollectionParams.CollectionAction.ADDREPLICAPROP;
 import static org.apache.solr.common.params.CollectionParams.CollectionAction.ADDROLE;
@@ -106,9 +104,6 @@ import static org.apache.solr.common.params.CommonParams.TIMING;
 import static org.apache.solr.common.params.CommonParams.VALUE_LONG;
 import static org.apache.solr.common.params.CoreAdminParams.BACKUP_LOCATION;
 import static org.apache.solr.common.params.CoreAdminParams.BACKUP_REPOSITORY;
-import static org.apache.solr.common.params.CoreAdminParams.DATA_DIR;
-import static org.apache.solr.common.params.CoreAdminParams.INSTANCE_DIR;
-import static org.apache.solr.common.params.CoreAdminParams.ULOG_DIR;
 import static org.apache.solr.common.params.ShardParams._ROUTE_;
 import static org.apache.solr.common.util.StrUtils.formatString;
 
@@ -175,7 +170,6 @@ import org.apache.solr.core.CoreContainer;
 import org.apache.solr.core.snapshots.CollectionSnapshotMetaData;
 import org.apache.solr.core.snapshots.SolrSnapshotManager;
 import org.apache.solr.handler.RequestHandlerBase;
-import org.apache.solr.handler.admin.api.AddReplicaAPI;
 import org.apache.solr.handler.admin.api.AddReplicaPropertyAPI;
 import org.apache.solr.handler.admin.api.AdminAPIBase;
 import org.apache.solr.handler.admin.api.AliasPropertyAPI;
@@ -187,6 +181,7 @@ import org.apache.solr.handler.admin.api.CreateAliasAPI;
 import org.apache.solr.handler.admin.api.CreateCollectionAPI;
 import org.apache.solr.handler.admin.api.CreateCollectionBackupAPI;
 import org.apache.solr.handler.admin.api.CreateCollectionSnapshotAPI;
+import org.apache.solr.handler.admin.api.CreateReplicaAPI;
 import org.apache.solr.handler.admin.api.CreateShardAPI;
 import org.apache.solr.handler.admin.api.DeleteAliasAPI;
 import org.apache.solr.handler.admin.api.DeleteCollectionAPI;
@@ -976,27 +971,17 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
     ADDREPLICA_OP(
         ADDREPLICA,
         (req, rsp, h) -> {
-          Map<String, Object> props =
-              copy(
-                  req.getParams(),
-                  null,
-                  COLLECTION_PROP,
-                  "node",
-                  SHARD_ID_PROP,
-                  _ROUTE_,
-                  CoreAdminParams.NAME,
-                  INSTANCE_DIR,
-                  DATA_DIR,
-                  ULOG_DIR,
-                  REPLICA_TYPE,
-                  WAIT_FOR_FINAL_STATE,
-                  NRT_REPLICAS,
-                  TLOG_REPLICAS,
-                  PULL_REPLICAS,
-                  CREATE_NODE_SET,
-                  FOLLOW_ALIASES,
-                  SKIP_NODE_ASSIGNMENT);
-          return copyPropertiesWithPrefix(req.getParams(), props, PROPERTY_PREFIX);
+          final var params = req.getParams();
+          params.required().check(COLLECTION_PROP, SHARD_ID_PROP);
+
+          final var api = new CreateReplicaAPI(h.coreContainer, req, rsp);
+          final var requestBody =
+              CreateReplicaAPI.AddReplicaRequestBody.fromV1Params(req.getParams());
+          final var response =
+              api.createReplica(
+                  params.get(COLLECTION_PROP), params.get(SHARD_ID_PROP), requestBody);
+          V2ApiUtils.squashIntoSolrResponseWithoutHeader(rsp, response);
+          return null;
         }),
     OVERSEERSTATUS_OP(OVERSEERSTATUS, (req, rsp, h) -> new LinkedHashMap<>()),
     DISTRIBUTEDAPIPROCESSING_OP(
@@ -1520,6 +1505,7 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
   @Override
   public Collection<Class<? extends JerseyResource>> getJerseyResources() {
     return List.of(
+        CreateReplicaAPI.class,
         AddReplicaPropertyAPI.class,
         CreateAliasAPI.class,
         CreateCollectionAPI.class,
@@ -1550,7 +1536,6 @@ public class CollectionsHandler extends RequestHandlerBase implements Permission
   public Collection<Api> getApis() {
     final List<Api> apis = new ArrayList<>();
     apis.addAll(AnnotatedApi.getApis(new SplitShardAPI(this)));
-    apis.addAll(AnnotatedApi.getApis(new AddReplicaAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new SyncShardAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new ForceLeaderAPI(this)));
     apis.addAll(AnnotatedApi.getApis(new BalanceShardUniqueAPI(this)));
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/AddReplicaAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/AddReplicaAPI.java
deleted file mode 100644
index caf545f029e..00000000000
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/AddReplicaAPI.java
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * 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.SolrRequest.METHOD.POST;
-import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
-import static org.apache.solr.common.params.CollectionAdminParams.CREATE_NODE_SET_PARAM;
-import static org.apache.solr.common.params.CollectionAdminParams.PROPERTY_PREFIX;
-import static org.apache.solr.common.params.CommonParams.ACTION;
-import static org.apache.solr.handler.ClusterAPI.wrapParams;
-import static org.apache.solr.handler.api.V2ApiUtils.flattenMapWithPrefix;
-import static org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
-
-import java.util.HashMap;
-import java.util.Map;
-import org.apache.solr.api.Command;
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.api.PayloadObj;
-import org.apache.solr.client.solrj.request.beans.AddReplicaPayload;
-import org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.handler.admin.CollectionsHandler;
-
-/**
- * V2 API for adding a new replica to an existing shard.
- *
- * <p>This API (POST /v2/collections/collectionName/shards {'add-replica': {...}}) is analogous to
- * the v1 /admin/collections?action=ADDREPLICA command.
- *
- * @see AddReplicaPayload
- */
-@EndPoint(
-    path = {"/c/{collection}/shards", "/collections/{collection}/shards"},
-    method = POST,
-    permission = COLL_EDIT_PERM)
-public class AddReplicaAPI {
-  private static final String V2_ADD_REPLICA_CMD = "add-replica";
-
-  private final CollectionsHandler collectionsHandler;
-
-  public AddReplicaAPI(CollectionsHandler collectionsHandler) {
-    this.collectionsHandler = collectionsHandler;
-  }
-
-  @Command(name = V2_ADD_REPLICA_CMD)
-  public void addReplica(PayloadObj<AddReplicaPayload> obj) throws Exception {
-    final AddReplicaPayload v2Body = obj.get();
-    final Map<String, Object> v1Params = v2Body.toMap(new HashMap<>());
-    v1Params.put(ACTION, CollectionParams.CollectionAction.ADDREPLICA.toLower());
-    v1Params.put(COLLECTION, obj.getRequest().getPathTemplateValues().get(COLLECTION));
-
-    flattenMapWithPrefix(v2Body.coreProperties, v1Params, PROPERTY_PREFIX);
-    if (v2Body.createNodeSet != null && !v2Body.createNodeSet.isEmpty()) {
-      v1Params.replace(CREATE_NODE_SET_PARAM, String.join(",", v2Body.createNodeSet));
-    }
-    collectionsHandler.handleRequestBody(wrapParams(obj.getRequest(), v1Params), obj.getResponse());
-  }
-}
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CreateReplicaAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CreateReplicaAPI.java
new file mode 100644
index 00000000000..76043bff05c
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CreateReplicaAPI.java
@@ -0,0 +1,202 @@
+/*
+ * 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.cloud.Overseer.QUEUE_OPERATION;
+import static org.apache.solr.cloud.api.collections.CollectionHandlingUtils.CREATE_NODE_SET;
+import static org.apache.solr.common.cloud.ZkStateReader.COLLECTION_PROP;
+import static org.apache.solr.common.cloud.ZkStateReader.NRT_REPLICAS;
+import static org.apache.solr.common.cloud.ZkStateReader.PULL_REPLICAS;
+import static org.apache.solr.common.cloud.ZkStateReader.REPLICA_TYPE;
+import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
+import static org.apache.solr.common.cloud.ZkStateReader.TLOG_REPLICAS;
+import static org.apache.solr.common.params.CollectionAdminParams.CREATE_NODE_SET_PARAM;
+import static org.apache.solr.common.params.CollectionAdminParams.FOLLOW_ALIASES;
+import static org.apache.solr.common.params.CollectionAdminParams.PROPERTY_PREFIX;
+import static org.apache.solr.common.params.CollectionAdminParams.SKIP_NODE_ASSIGNMENT;
+import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
+import static org.apache.solr.common.params.CommonAdminParams.WAIT_FOR_FINAL_STATE;
+import static org.apache.solr.common.params.CoreAdminParams.DATA_DIR;
+import static org.apache.solr.common.params.CoreAdminParams.INSTANCE_DIR;
+import static org.apache.solr.common.params.CoreAdminParams.NAME;
+import static org.apache.solr.common.params.CoreAdminParams.NODE;
+import static org.apache.solr.common.params.CoreAdminParams.ULOG_DIR;
+import static org.apache.solr.common.params.ShardParams._ROUTE_;
+import static org.apache.solr.handler.admin.api.CreateCollectionAPI.copyPrefixedPropertiesWithoutPrefix;
+import static org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.inject.Inject;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.cloud.ZkNodeProps;
+import org.apache.solr.common.params.CollectionParams;
+import org.apache.solr.common.params.CoreAdminParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.CollectionUtil;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.jersey.JacksonReflectMapWriter;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SubResponseAccumulatingJerseyResponse;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+
+/**
+ * V2 API for adding a new replica to an existing shard.
+ *
+ * <p>This API (POST /v2/collections/cName/shards/sName/replicas {...}) is analogous to the v1
+ * /admin/collections?action=ADDREPLICA command.
+ */
+@Path("/collections/{collectionName}/shards/{shardName}/replicas")
+public class CreateReplicaAPI extends AdminAPIBase {
+
+  @Inject
+  public CreateReplicaAPI(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse) {
+    super(coreContainer, solrQueryRequest, solrQueryResponse);
+  }
+
+  @POST
+  @Produces({"application/json", "application/xml", BINARY_CONTENT_TYPE_V2})
+  @PermissionName(COLL_EDIT_PERM)
+  public SubResponseAccumulatingJerseyResponse createReplica(
+      @PathParam("collectionName") String collectionName,
+      @PathParam("shardName") String shardName,
+      AddReplicaRequestBody requestBody)
+      throws Exception {
+    final var response = instantiateJerseyResponse(SubResponseAccumulatingJerseyResponse.class);
+    if (requestBody == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST, "Required request-body is missing");
+    }
+    ensureRequiredParameterProvided(COLLECTION_PROP, collectionName);
+    ensureRequiredParameterProvided(SHARD_ID_PROP, shardName);
+    final String resolvedCollectionName =
+        resolveAndValidateAliasIfEnabled(
+            collectionName, Boolean.TRUE.equals(requestBody.followAliases));
+
+    final ZkNodeProps remoteMessage =
+        createRemoteMessage(resolvedCollectionName, shardName, requestBody);
+    submitRemoteMessageAndHandleResponse(
+        response, CollectionParams.CollectionAction.ADDREPLICA, remoteMessage, requestBody.asyncId);
+    return response;
+  }
+
+  public static ZkNodeProps createRemoteMessage(
+      String collectionName, String shardName, AddReplicaRequestBody requestBody) {
+    final Map<String, Object> remoteMessage = new HashMap<>();
+    remoteMessage.put(QUEUE_OPERATION, CollectionParams.CollectionAction.ADDREPLICA.toLower());
+    remoteMessage.put(COLLECTION_PROP, collectionName);
+    remoteMessage.put(SHARD_ID_PROP, shardName);
+    insertIfNotNull(remoteMessage, CoreAdminParams.NAME, requestBody.name);
+    insertIfNotNull(remoteMessage, _ROUTE_, requestBody.route);
+    insertIfNotNull(remoteMessage, NODE, requestBody.node);
+    if (CollectionUtil.isNotEmpty(requestBody.nodeSet)) {
+      remoteMessage.put(CREATE_NODE_SET_PARAM, String.join(",", requestBody.nodeSet));
+    }
+    insertIfNotNull(remoteMessage, SKIP_NODE_ASSIGNMENT, requestBody.skipNodeAssignment);
+    insertIfNotNull(remoteMessage, INSTANCE_DIR, requestBody.instanceDir);
+    insertIfNotNull(remoteMessage, DATA_DIR, requestBody.dataDir);
+    insertIfNotNull(remoteMessage, ULOG_DIR, requestBody.ulogDir);
+    insertIfNotNull(remoteMessage, REPLICA_TYPE, requestBody.type);
+    insertIfNotNull(remoteMessage, WAIT_FOR_FINAL_STATE, requestBody.waitForFinalState);
+    insertIfNotNull(remoteMessage, NRT_REPLICAS, requestBody.nrtReplicas);
+    insertIfNotNull(remoteMessage, TLOG_REPLICAS, requestBody.tlogReplicas);
+    insertIfNotNull(remoteMessage, PULL_REPLICAS, requestBody.pullReplicas);
+    insertIfNotNull(remoteMessage, FOLLOW_ALIASES, requestBody.followAliases);
+    insertIfNotNull(remoteMessage, ASYNC, requestBody.asyncId);
+
+    if (requestBody.properties != null) {
+      requestBody
+          .properties
+          .entrySet()
+          .forEach(
+              entry -> {
+                remoteMessage.put(PROPERTY_PREFIX + entry.getKey(), entry.getValue());
+              });
+    }
+
+    return new ZkNodeProps(remoteMessage);
+  }
+
+  public static class AddReplicaRequestBody implements JacksonReflectMapWriter {
+    @JsonProperty public String name;
+    @JsonProperty public String type; // TODO Make this an enum - see SOLR-15796
+    @JsonProperty public String instanceDir;
+    @JsonProperty public String dataDir;
+    @JsonProperty public String ulogDir;
+    @JsonProperty public String route;
+    @JsonProperty public Integer nrtReplicas;
+    @JsonProperty public Integer tlogReplicas;
+    @JsonProperty public Integer pullReplicas;
+    @JsonProperty public Boolean waitForFinalState;
+    @JsonProperty public Boolean followAliases;
+
+    @JsonProperty(ASYNC)
+    public String asyncId;
+
+    // TODO This cluster of properties could probably be simplified down to just "nodeSet".  See
+    // SOLR-15542
+    @JsonProperty public String node;
+
+    @JsonProperty("nodeSet")
+    public List<String> nodeSet;
+
+    @JsonProperty public Boolean skipNodeAssignment;
+
+    @JsonProperty public Map<String, String> properties;
+
+    public static AddReplicaRequestBody fromV1Params(SolrParams params) {
+      final var requestBody = new AddReplicaRequestBody();
+
+      requestBody.name = params.get(NAME);
+      requestBody.type = params.get(REPLICA_TYPE);
+      requestBody.instanceDir = params.get(INSTANCE_DIR);
+      requestBody.dataDir = params.get(DATA_DIR);
+      requestBody.ulogDir = params.get(ULOG_DIR);
+      requestBody.route = params.get(_ROUTE_);
+      requestBody.nrtReplicas = params.getInt(NRT_REPLICAS);
+      requestBody.tlogReplicas = params.getInt(TLOG_REPLICAS);
+      requestBody.pullReplicas = params.getInt(PULL_REPLICAS);
+      requestBody.waitForFinalState = params.getBool(WAIT_FOR_FINAL_STATE);
+      requestBody.followAliases = params.getBool(FOLLOW_ALIASES);
+      requestBody.asyncId = params.get(ASYNC);
+
+      requestBody.node = params.get(NODE);
+      if (params.get(CREATE_NODE_SET_PARAM) != null) {
+        requestBody.nodeSet = Arrays.asList(params.get(CREATE_NODE_SET).split(","));
+      }
+      requestBody.skipNodeAssignment = params.getBool(SKIP_NODE_ASSIGNMENT);
+
+      requestBody.properties =
+          copyPrefixedPropertiesWithoutPrefix(params, new HashMap<>(), PROPERTY_PREFIX);
+
+      return requestBody;
+    }
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/cloud/TestPullReplica.java b/solr/core/src/test/org/apache/solr/cloud/TestPullReplica.java
index 6bc17396b33..a5b0ee21781 100644
--- a/solr/core/src/test/org/apache/solr/cloud/TestPullReplica.java
+++ b/solr/core/src/test/org/apache/solr/cloud/TestPullReplica.java
@@ -867,11 +867,11 @@ public class TestPullReplica extends SolrCloudTestCase {
         url =
             String.format(
                 Locale.ROOT,
-                "%s/____v2/c/%s/shards",
+                "%s/____v2/collections/%s/shards/%s/replicas",
                 cluster.getRandomJetty(random()).getBaseUrl(),
-                collectionName);
-        String requestBody =
-            String.format(Locale.ROOT, "{add-replica:{shard:%s, type:%s}}", shardName, type);
+                collectionName,
+                shardName);
+        String requestBody = String.format(Locale.ROOT, "{\"type\": \"%s\"}", type);
         HttpPost addReplicaPost = new HttpPost(url);
         addReplicaPost.setHeader("Content-type", "application/json");
         addReplicaPost.setEntity(new StringEntity(requestBody));
diff --git a/solr/core/src/test/org/apache/solr/cloud/TestTlogReplica.java b/solr/core/src/test/org/apache/solr/cloud/TestTlogReplica.java
index ff391813670..937b8f1fe17 100644
--- a/solr/core/src/test/org/apache/solr/cloud/TestTlogReplica.java
+++ b/solr/core/src/test/org/apache/solr/cloud/TestTlogReplica.java
@@ -38,6 +38,7 @@ import org.apache.http.client.HttpClient;
 import org.apache.http.client.methods.HttpGet;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.SolrQuery;
@@ -359,15 +360,19 @@ public class TestTlogReplica extends SolrCloudTestCase {
         url =
             String.format(
                 Locale.ROOT,
-                "%s/____v2/c/%s/shards",
+                "%s/____v2/collections/%s/shards/%s/replicas",
                 cluster.getRandomJetty(random()).getBaseUrl(),
-                collectionName);
-        String requestBody =
-            String.format(Locale.ROOT, "{add-replica:{shard:%s, type:%s}}", shardName, type);
+                collectionName,
+                shardName);
+        String requestBody = String.format(Locale.ROOT, "{\"type\": \"%s\"}", type);
         HttpPost addReplicaPost = new HttpPost(url);
         addReplicaPost.setHeader("Content-type", "application/json");
         addReplicaPost.setEntity(new StringEntity(requestBody));
         httpResponse = getHttpClient().execute(addReplicaPost);
+        if (httpResponse.getStatusLine().getStatusCode() == 400) {
+          final String entity = EntityUtils.toString(httpResponse.getEntity());
+          System.out.println(entity);
+        }
         assertEquals(200, httpResponse.getStatusLine().getStatusCode());
         break;
     }
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java b/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
index 07601cc624d..e416d35bf5b 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/TestApiFramework.java
@@ -100,12 +100,7 @@ public class TestApiFramework extends SolrTestCaseJ4 {
     parts = new HashMap<>();
     api =
         V2HttpCall.getApiInfo(containerHandlers, "/collections/hello/shards", "POST", null, parts);
-    assertConditions(
-        api.getSpec(),
-        Map.of(
-            "/methods[0]", "POST",
-            "/commands/split", NOT_NULL,
-            "/commands/add-replica", NOT_NULL));
+    assertConditions(api.getSpec(), Map.of("/methods[0]", "POST", "/commands/split", NOT_NULL));
 
     parts = new HashMap<>();
     api =
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java b/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
index 1411452d66a..fa11d4aa4cb 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/TestCollectionAPIs.java
@@ -98,13 +98,6 @@ public class TestCollectionAPIs extends SolrTestCaseJ4 {
         "{split:{shard:shard1, ranges: '0-1f4,1f5-3e8,3e9-5dc', coreProperties : {prop1:prop1Val, prop2:prop2Val} }}",
         "{collection: collName , shard : shard1, ranges :'0-1f4,1f5-3e8,3e9-5dc', operation : splitshard, property.prop1:prop1Val, property.prop2: prop2Val}");
 
-    compareOutput(
-        apiBag,
-        "/collections/collName/shards",
-        POST,
-        "{add-replica:{shard: shard1, node: 'localhost_8978' , coreProperties : {prop1:prop1Val, prop2:prop2Val} }}",
-        "{collection: collName , shard : shard1, node :'localhost_8978', operation : addreplica, property.prop1:prop1Val, property.prop2: prop2Val}");
-
     compareOutput(
         apiBag,
         "/collections/collName/shards",
@@ -112,28 +105,6 @@ public class TestCollectionAPIs extends SolrTestCaseJ4 {
         "{split:{ splitKey:id12345, coreProperties : {prop1:prop1Val, prop2:prop2Val} }}",
         "{collection: collName , split.key : id12345 , operation : splitshard, property.prop1:prop1Val, property.prop2: prop2Val}");
 
-    compareOutput(
-        apiBag,
-        "/collections/collName/shards",
-        POST,
-        "{add-replica:{shard: shard1, node: 'localhost_8978' , type:'TLOG' }}",
-        "{collection: collName , shard : shard1, node :'localhost_8978', operation : addreplica, type: TLOG}");
-
-    compareOutput(
-        apiBag,
-        "/collections/collName/shards",
-        POST,
-        "{add-replica:{shard: shard1, node: 'localhost_8978' , type:'PULL' }}",
-        "{collection: collName , shard : shard1, node :'localhost_8978', operation : addreplica, type: PULL}");
-
-    // TODO annotation-based v2 APIs still miss enum support to validate the 'type' parameter as
-    // this test requires.
-    // Uncomment this test after fixing SOLR-15796
-    //    assertErrorContains(apiBag, "/collections/collName/shards", POST,
-    //        "{add-replica:{shard: shard1, node: 'localhost_8978' , type:'foo' }}", null,
-    //        "Value of enum must be one of"
-    //    );
-
     compareOutput(
         apiBag,
         "/cluster",
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/CreateReplicaAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/CreateReplicaAPITest.java
new file mode 100644
index 00000000000..d4e9772e866
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/CreateReplicaAPITest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.cloud.Overseer.QUEUE_OPERATION;
+import static org.apache.solr.common.cloud.ZkStateReader.NRT_REPLICAS;
+import static org.apache.solr.common.cloud.ZkStateReader.PULL_REPLICAS;
+import static org.apache.solr.common.cloud.ZkStateReader.REPLICA_TYPE;
+import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
+import static org.apache.solr.common.cloud.ZkStateReader.TLOG_REPLICAS;
+import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
+import static org.apache.solr.common.params.CollectionAdminParams.CREATE_NODE_SET_PARAM;
+import static org.apache.solr.common.params.CollectionAdminParams.FOLLOW_ALIASES;
+import static org.apache.solr.common.params.CollectionAdminParams.SKIP_NODE_ASSIGNMENT;
+import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
+import static org.apache.solr.common.params.CommonAdminParams.WAIT_FOR_FINAL_STATE;
+import static org.apache.solr.common.params.CoreAdminParams.DATA_DIR;
+import static org.apache.solr.common.params.CoreAdminParams.INSTANCE_DIR;
+import static org.apache.solr.common.params.CoreAdminParams.NAME;
+import static org.apache.solr.common.params.CoreAdminParams.NODE;
+import static org.apache.solr.common.params.CoreAdminParams.ULOG_DIR;
+import static org.apache.solr.common.params.ShardParams._ROUTE_;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.junit.Test;
+
+/** Unit tests for {@link CreateReplicaAPI} */
+public class CreateReplicaAPITest extends SolrTestCaseJ4 {
+  @Test
+  public void testReportsErrorIfRequestBodyMissing() {
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new CreateReplicaAPI(null, null, null);
+              api.createReplica("someCollName", "someShardName", null);
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Required request-body is missing", thrown.getMessage());
+  }
+
+  @Test
+  public void testReportsErrorIfCollectionNameMissing() {
+    final var requestBody = new CreateReplicaAPI.AddReplicaRequestBody();
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new CreateReplicaAPI(null, null, null);
+              api.createReplica(null, "shardName", requestBody);
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: collection", thrown.getMessage());
+  }
+
+  @Test
+  public void testReportsErrorIfShardNameMissing() {
+    final var requestBody = new CreateReplicaAPI.AddReplicaRequestBody();
+    final SolrException thrown =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              final var api = new CreateReplicaAPI(null, null, null);
+              api.createReplica("someCollectionName", null, requestBody);
+            });
+
+    assertEquals(400, thrown.code());
+    assertEquals("Missing required parameter: shard", thrown.getMessage());
+  }
+
+  @Test
+  public void testCreateRemoteMessageAllProperties() {
+    final var requestBody = new CreateReplicaAPI.AddReplicaRequestBody();
+    requestBody.name = "someName";
+    requestBody.type = "NRT";
+    requestBody.instanceDir = "/some/dir1";
+    requestBody.dataDir = "/some/dir2";
+    requestBody.ulogDir = "/some/dir3";
+    requestBody.route = "someRoute";
+    requestBody.nrtReplicas = 123;
+    requestBody.tlogReplicas = 456;
+    requestBody.pullReplicas = 789;
+    requestBody.nodeSet = List.of("node1", "node2");
+    requestBody.node = "node3";
+    requestBody.skipNodeAssignment = Boolean.TRUE;
+    requestBody.waitForFinalState = true;
+    requestBody.followAliases = true;
+    requestBody.asyncId = "someAsyncId";
+    requestBody.properties = Map.of("propName1", "propVal1", "propName2", "propVal2");
+
+    final var remoteMessage =
+        CreateReplicaAPI.createRemoteMessage("someCollectionName", "someShardName", requestBody)
+            .getProperties();
+
+    assertEquals(20, remoteMessage.size());
+    assertEquals("addreplica", remoteMessage.get(QUEUE_OPERATION));
+    assertEquals("someCollectionName", remoteMessage.get(COLLECTION));
+    assertEquals("someShardName", remoteMessage.get(SHARD_ID_PROP));
+    assertEquals("someName", remoteMessage.get(NAME));
+    assertEquals("NRT", remoteMessage.get(REPLICA_TYPE));
+    assertEquals("/some/dir1", remoteMessage.get(INSTANCE_DIR));
+    assertEquals("/some/dir2", remoteMessage.get(DATA_DIR));
+    assertEquals("/some/dir3", remoteMessage.get(ULOG_DIR));
+    assertEquals("someRoute", remoteMessage.get(_ROUTE_));
+    assertEquals(123, remoteMessage.get(NRT_REPLICAS));
+    assertEquals(456, remoteMessage.get(TLOG_REPLICAS));
+    assertEquals(789, remoteMessage.get(PULL_REPLICAS));
+    assertEquals("node1,node2", remoteMessage.get(CREATE_NODE_SET_PARAM));
+    assertEquals("node3", remoteMessage.get(NODE));
+    assertEquals(Boolean.TRUE, remoteMessage.get(SKIP_NODE_ASSIGNMENT));
+    assertEquals(true, remoteMessage.get(WAIT_FOR_FINAL_STATE));
+    assertEquals(true, remoteMessage.get(FOLLOW_ALIASES));
+    assertEquals("someAsyncId", remoteMessage.get(ASYNC));
+    assertEquals("propVal1", remoteMessage.get("property.propName1"));
+    assertEquals("propVal2", remoteMessage.get("property.propName2"));
+  }
+
+  @Test
+  public void testCanConvertV1ParamsToV2RequestBody() {
+    final var v1Params = new ModifiableSolrParams();
+    v1Params.add(COLLECTION, "someCollectionName");
+    v1Params.add(SHARD_ID_PROP, "someShardName");
+    v1Params.add(NAME, "someName");
+    v1Params.add(REPLICA_TYPE, "NRT");
+    v1Params.add(INSTANCE_DIR, "/some/dir1");
+    v1Params.add(DATA_DIR, "/some/dir2");
+    v1Params.add(ULOG_DIR, "/some/dir3");
+    v1Params.add(_ROUTE_, "someRoute");
+    v1Params.set(NRT_REPLICAS, 123);
+    v1Params.set(TLOG_REPLICAS, 456);
+    v1Params.set(PULL_REPLICAS, 789);
+    v1Params.add(CREATE_NODE_SET_PARAM, "node1,node2");
+    v1Params.add(NODE, "node3");
+    v1Params.set(SKIP_NODE_ASSIGNMENT, true);
+    v1Params.set(WAIT_FOR_FINAL_STATE, true);
+    v1Params.set(FOLLOW_ALIASES, true);
+    v1Params.add(ASYNC, "someAsyncId");
+    v1Params.add("property.propName1", "propVal1");
+    v1Params.add("property.propName2", "propVal2");
+
+    final var requestBody = CreateReplicaAPI.AddReplicaRequestBody.fromV1Params(v1Params);
+
+    assertEquals("someName", requestBody.name);
+    assertEquals("NRT", requestBody.type);
+    assertEquals("/some/dir1", requestBody.instanceDir);
+    assertEquals("/some/dir2", requestBody.dataDir);
+    assertEquals("/some/dir3", requestBody.ulogDir);
+    assertEquals("someRoute", requestBody.route);
+    assertEquals(Integer.valueOf(123), requestBody.nrtReplicas);
+    assertEquals(Integer.valueOf(456), requestBody.tlogReplicas);
+    assertEquals(Integer.valueOf(789), requestBody.pullReplicas);
+    assertEquals(List.of("node1", "node2"), requestBody.nodeSet);
+    assertEquals("node3", requestBody.node);
+    assertEquals(Boolean.TRUE, requestBody.skipNodeAssignment);
+    assertEquals(Boolean.TRUE, requestBody.waitForFinalState);
+    assertEquals(Boolean.TRUE, requestBody.followAliases);
+    assertEquals("someAsyncId", requestBody.asyncId);
+    assertEquals("propVal1", requestBody.properties.get("propName1"));
+    assertEquals("propVal2", requestBody.properties.get("propName2"));
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java
index 3d8d1448329..0d584973a7a 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java
@@ -19,9 +19,7 @@ package org.apache.solr.handler.admin.api;
 
 import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
 import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
-import static org.apache.solr.common.params.CollectionAdminParams.CREATE_NODE_SET_PARAM;
 import static org.apache.solr.common.params.CollectionAdminParams.FOLLOW_ALIASES;
-import static org.apache.solr.common.params.CollectionParams.NAME;
 import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
 import static org.apache.solr.common.params.CommonAdminParams.NUM_SUB_SHARDS;
 import static org.apache.solr.common.params.CommonAdminParams.SPLIT_BY_PREFIX;
@@ -55,7 +53,6 @@ public class V2ShardsAPIMappingTest extends V2ApiMappingTest<CollectionsHandler>
   public void populateApiBag() {
     final CollectionsHandler collectionsHandler = getRequestHandler();
     apiBag.registerObject(new SplitShardAPI(collectionsHandler));
-    apiBag.registerObject(new AddReplicaAPI(collectionsHandler));
     apiBag.registerObject(new SyncShardAPI(collectionsHandler));
     apiBag.registerObject(new ForceLeaderAPI(collectionsHandler));
   }
@@ -131,48 +128,4 @@ public class V2ShardsAPIMappingTest extends V2ApiMappingTest<CollectionsHandler>
     assertEquals("foo1", v1Params.get("property.foo"));
     assertEquals("bar1", v1Params.get("property.bar"));
   }
-
-  @Test
-  public void testAddReplicaAllProperties() throws Exception {
-    final SolrParams v1Params =
-        captureConvertedV1Params(
-            "/collections/collName/shards",
-            "POST",
-            "{ 'add-replica': {"
-                + "'shard': 'shard1', "
-                + "'_route_': 'someRouteValue', "
-                + "'node': 'someNodeValue', "
-                + "'name': 'someName', "
-                + "'instanceDir': 'dir1', "
-                + "'dataDir': 'dir2', "
-                + "'ulogDir': 'dir3', "
-                + "'createNodeSet': ['foo', 'bar', 'baz'], "
-                + "'followAliases': true, "
-                + "'async': 'some_async_id', "
-                + "'waitForFinalState': true, "
-                + "'skipNodeAssignment': true, "
-                + "'type': 'tlog', "
-                + "'coreProperties': {"
-                + "    'foo': 'foo1', "
-                + "    'bar': 'bar1', "
-                + "}}}");
-
-    assertEquals(CollectionParams.CollectionAction.ADDREPLICA.lowerName, v1Params.get(ACTION));
-    assertEquals("collName", v1Params.get(COLLECTION));
-    assertEquals("shard1", v1Params.get(SHARD_ID_PROP));
-    assertEquals("someRouteValue", v1Params.get("_route_"));
-    assertEquals("someNodeValue", v1Params.get("node"));
-    assertEquals("foo,bar,baz", v1Params.get(CREATE_NODE_SET_PARAM));
-    assertEquals("someName", v1Params.get(NAME));
-    assertEquals("dir1", v1Params.get("instanceDir"));
-    assertEquals("dir2", v1Params.get("dataDir"));
-    assertEquals("dir3", v1Params.get("ulogDir"));
-    assertTrue(v1Params.getPrimitiveBool(FOLLOW_ALIASES));
-    assertEquals("some_async_id", v1Params.get(ASYNC));
-    assertTrue(v1Params.getPrimitiveBool(WAIT_FOR_FINAL_STATE));
-    assertTrue(v1Params.getPrimitiveBool("skipNodeAssignment"));
-    assertEquals("tlog", v1Params.get("type"));
-    assertEquals("foo1", v1Params.get("property.foo"));
-    assertEquals("bar1", v1Params.get("property.bar"));
-  }
 }
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/replica-management.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/replica-management.adoc
index 39667652845..3a0e7c700d2 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/replica-management.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/replica-management.adoc
@@ -73,12 +73,9 @@ http://localhost:8983/solr/admin/collections?action=ADDREPLICA&collection=techpr
 
 [source,bash]
 ----
-curl -X POST http://localhost:8983/api/collections/techproducts/shards -H 'Content-Type: application/json' -d '
+curl -X POST http://localhost:8983/api/collections/techproducts/shards/shard1/replicas -H 'Content-Type: application/json' -d '
   {
-    "add-replica":{
-      "shard":"shard1",
-      "node":"localhost:8983_solr"
-    }
+    "node":"localhost:8983_solr"
   }
 '
 ----
@@ -123,7 +120,7 @@ The name of the shard to which replica is to be added.
 +
 If `shard` is not specified, then `\_route_` must be.
 
-`\_route_`::
+`\_route_` (v1), `route` (v2)::
 +
 [%autowidth,frame=none]
 |===
@@ -143,18 +140,19 @@ Ignored if the `shard` parameter is also specified.
 +
 The name of the node where the replica should be created.
 
-`createNodeSet`::
+`createNodeSet` (v1), `nodeSet` (v2)::
 +
 [%autowidth,frame=none]
 |===
 |Optional |Default: none
 |===
 +
-A comma-separated list of nodes among which the best ones will be chosen to place the replicas.
+Placement candidates for the newly created replica(s).
 +
-The format is a comma-separated list of node_names, such as `localhost:8983_solr,localhost:8984_solr,localhost:8985_solr`.
+Provided as a comma-separated list of node names in v1 requests, such as `localhost:8983_solr,localhost:8984_solr,localhost:8985_solr`.
+In v2 requests, `nodeSet` expects the values as a true list, such as `["localhost:8983_solr", "localhost:8984_solr", "localhost:8985_solr"]`.
 
-NOTE: If neither `node` nor `createNodeSet` are specified then the best node(s) from among all the live nodes in the cluster are chosen.
+NOTE: If neither `node` nor `createNodeSet`/`nodeSet` are specified then the best node(s) from among all the live nodes in the cluster are chosen.
 
 `instanceDir`::
 +
@@ -228,7 +226,7 @@ Defaults to `1` if `type` is `pull` otherwise `0`.
 |Optional |Default: none
 |===
 +
-Set core property _name_ to _value_.
+Name/value pairs to use as additional properties in the created core.
 See xref:configuration-guide:core-discovery.adoc[] for details about supported properties and values.
 
 [WARNING]
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/AddReplicaPayload.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/AddReplicaPayload.java
deleted file mode 100644
index ca061cf7823..00000000000
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/request/beans/AddReplicaPayload.java
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * 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.client.solrj.request.beans;
-
-import java.util.List;
-import java.util.Map;
-import org.apache.solr.common.annotation.JsonProperty;
-import org.apache.solr.common.util.ReflectMapWriter;
-
-public class AddReplicaPayload implements ReflectMapWriter {
-  @JsonProperty public String shard;
-
-  @JsonProperty public String _route_;
-
-  // TODO Remove in favor of a createNodeSet/nodeSet param with size=1 (see SOLR-15542)
-  @JsonProperty public String node;
-
-  // TODO Rename to 'nodeSet' to match the name used by create-shard and other APIs (see SOLR-15542)
-  @JsonProperty public List<String> createNodeSet;
-
-  @JsonProperty public String name;
-
-  @JsonProperty public String instanceDir;
-
-  @JsonProperty public String dataDir;
-
-  @JsonProperty public String ulogDir;
-
-  @JsonProperty public Map<String, Object> coreProperties;
-
-  @JsonProperty public String async;
-
-  @JsonProperty public Boolean waitForFinalState;
-
-  @JsonProperty public Boolean followAliases;
-
-  @JsonProperty public Boolean skipNodeAssignment;
-
-  // TODO Make this an enum - see SOLR-15796
-  @JsonProperty public String type;
-}