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

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


The following commit(s) were added to refs/heads/main by this push:
     new 1fdb455a524 SOLR-16398: Migrate split shard v2 API to JAX-RS (#5265)
1fdb455a524 is described below

commit 1fdb455a524a7b8d334d5baea04e395ed4d2d7f8
Author: Nick <[email protected]>
AuthorDate: Tue Nov 3 09:44:13 2026 -0700

    SOLR-16398: Migrate split shard v2 API to JAX-RS (#5265)
---
 changelog/unreleased/SOLR-16398.yml                |   7 +
 .../solr/client/api/endpoint/SplitShardApi.java    |  39 +++
 .../client/api/model/SplitShardRequestBody.java    |  53 +++
 .../solr/client/api/model/SplitShardResponse.java  |  27 ++
 .../solr/handler/admin/CollectionsHandler.java     |  64 +---
 .../solr/handler/admin/api/AdminAPIBase.java       |  44 ++-
 .../apache/solr/handler/admin/api/SplitShard.java  | 213 +++++++++++
 .../solr/handler/admin/api/SplitShardAPI.java      |  74 ----
 .../solr/handler/admin/TestApiFramework.java       |  10 +-
 .../solr/handler/admin/TestCollectionAPIs.java     |  14 -
 .../solr/handler/admin/api/SplitShardAPITest.java  | 139 ++++++++
 .../admin/api/SplitShardMessageMappingTest.java    | 390 +++++++++++++++++++++
 .../handler/admin/api/V2ShardsAPIMappingTest.java  | 106 ------
 13 files changed, 915 insertions(+), 265 deletions(-)

diff --git a/changelog/unreleased/SOLR-16398.yml 
b/changelog/unreleased/SOLR-16398.yml
new file mode 100644
index 00000000000..f49fa1992c2
--- /dev/null
+++ b/changelog/unreleased/SOLR-16398.yml
@@ -0,0 +1,7 @@
+title: Migrate the v2 split-shard API to JAX-RS
+type: added
+authors:
+  - name: Nick Shanin
+links:
+  - name: SOLR-16398
+    url: https://issues.apache.org/jira/browse/SOLR-16398
diff --git 
a/solr/api/src/java/org/apache/solr/client/api/endpoint/SplitShardApi.java 
b/solr/api/src/java/org/apache/solr/client/api/endpoint/SplitShardApi.java
new file mode 100644
index 00000000000..3dd0daec495
--- /dev/null
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SplitShardApi.java
@@ -0,0 +1,39 @@
+/*
+ * 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.api.endpoint;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import org.apache.solr.client.api.model.SplitShardRequestBody;
+import org.apache.solr.client.api.model.SplitShardResponse;
+
+@Path("/collections/{collectionName}/shards/{shardName}/split")
+public interface SplitShardApi {
+  @POST
+  @Operation(
+      summary = "Split an existing shard into two or more new shards.",
+      tags = {"shards"})
+  SplitShardResponse splitShard(
+      @PathParam("collectionName") String collectionName,
+      @PathParam("shardName") String shardName,
+      @RequestBody(description = "Contains user provided parameters")
+          SplitShardRequestBody requestBody)
+      throws Exception;
+}
diff --git 
a/solr/api/src/java/org/apache/solr/client/api/model/SplitShardRequestBody.java 
b/solr/api/src/java/org/apache/solr/client/api/model/SplitShardRequestBody.java
new file mode 100644
index 00000000000..600f54a4996
--- /dev/null
+++ 
b/solr/api/src/java/org/apache/solr/client/api/model/SplitShardRequestBody.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.client.api.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Map;
+
+public class SplitShardRequestBody {
+  @JsonProperty public String ranges;
+
+  @JsonProperty public String splitKey;
+
+  @JsonProperty public Integer numSubShards;
+
+  @JsonProperty public String splitFuzz;
+
+  @JsonProperty public Boolean timing;
+
+  @JsonProperty public Boolean splitByPrefix;
+
+  @JsonProperty public Boolean followAliases;
+
+  @JsonProperty public String splitMethod;
+
+  @JsonProperty public String createNodeSet;
+
+  @JsonProperty public Map<String, Object> coreProperties;
+
+  @JsonProperty public String async;
+
+  /**
+   * @deprecated Solr is moving toward always waiting for final state, with no 
option to opt out;
+   *     once that happens, this parameter will have no effect and will likely 
be removed. See
+   *     SOLR-17712.
+   */
+  @JsonProperty
+  @Deprecated(since = "9.10")
+  public Boolean waitForFinalState;
+}
diff --git 
a/solr/api/src/java/org/apache/solr/client/api/model/SplitShardResponse.java 
b/solr/api/src/java/org/apache/solr/client/api/model/SplitShardResponse.java
new file mode 100644
index 00000000000..58e96337e49
--- /dev/null
+++ b/solr/api/src/java/org/apache/solr/client/api/model/SplitShardResponse.java
@@ -0,0 +1,27 @@
+/*
+ * 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.api.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Map;
+
+/** Response from splitting a shard, including optional per-phase timing 
information. */
+public class SplitShardResponse extends SubResponseAccumulatingJerseyResponse {
+
+  @JsonProperty public Map<String, Object> timing;
+}
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 28d2e3af535..4fe1f3a9ba2 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
@@ -30,7 +30,6 @@ import static 
org.apache.solr.common.cloud.ZkStateReader.REPLICA_PROP;
 import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
 import static 
org.apache.solr.common.params.CollectionAdminParams.CALLING_LOCK_ID_HEADER;
 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.PROPERTY_NAME;
 import static 
org.apache.solr.common.params.CollectionAdminParams.PROPERTY_PREFIX;
@@ -83,13 +82,8 @@ import static 
org.apache.solr.common.params.CollectionParams.SOURCE_NODE;
 import static org.apache.solr.common.params.CollectionParams.TARGET_NODE;
 import static org.apache.solr.common.params.CommonAdminParams.ASYNC;
 import static org.apache.solr.common.params.CommonAdminParams.IN_PLACE_MOVE;
-import static org.apache.solr.common.params.CommonAdminParams.NUM_SUB_SHARDS;
-import static org.apache.solr.common.params.CommonAdminParams.SPLIT_BY_PREFIX;
-import static org.apache.solr.common.params.CommonAdminParams.SPLIT_FUZZ;
-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.NAME;
-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;
@@ -203,7 +197,7 @@ import 
org.apache.solr.handler.admin.api.ReloadCollectionAPI;
 import org.apache.solr.handler.admin.api.RenameCollection;
 import org.apache.solr.handler.admin.api.ReplaceNode;
 import org.apache.solr.handler.admin.api.RestoreCollection;
-import org.apache.solr.handler.admin.api.SplitShardAPI;
+import org.apache.solr.handler.admin.api.SplitShard;
 import org.apache.solr.handler.admin.api.SyncShard;
 import org.apache.solr.handler.api.V2ApiUtils;
 import org.apache.solr.logging.MDCLoggingContext;
@@ -638,61 +632,15 @@ public class CollectionsHandler extends 
RequestHandlerBase implements Permission
           return null;
         }),
     /**
-     * V2 equivalent: {@link SplitShardAPI} ({@code POST 
/collections/{collection}/shards} with the
-     * {@code split} command).
+     * V2 equivalent: {@link org.apache.solr.handler.admin.api.SplitShard} 
({@code POST
+     * /collections/{collection}/shards/{shard}/split}).
      */
     SPLITSHARD_OP(
         SPLITSHARD,
         DEFAULT_COLLECTION_OP_TIMEOUT * 5,
         (req, rsp, h) -> {
-          String name = req.getParams().required().get(COLLECTION_PROP);
-          // TODO : add support for multiple shards
-          String shard = req.getParams().get(SHARD_ID_PROP);
-          String rangesStr = req.getParams().get(CoreAdminParams.RANGES);
-          String splitKey = req.getParams().get("split.key");
-          String numSubShards = req.getParams().get(NUM_SUB_SHARDS);
-          String fuzz = req.getParams().get(SPLIT_FUZZ);
-
-          if (splitKey == null && shard == null) {
-            throw new SolrException(
-                ErrorCode.BAD_REQUEST, "At least one of shard, or split.key 
should be specified.");
-          }
-          if (splitKey != null && shard != null) {
-            throw new SolrException(
-                ErrorCode.BAD_REQUEST, "Only one of 'shard' or 'split.key' 
should be specified");
-          }
-          if (splitKey != null && rangesStr != null) {
-            throw new SolrException(
-                ErrorCode.BAD_REQUEST, "Only one of 'ranges' or 'split.key' 
should be specified");
-          }
-          if (numSubShards != null && (splitKey != null || rangesStr != null)) 
{
-            throw new SolrException(
-                ErrorCode.BAD_REQUEST,
-                "numSubShards can not be specified with split.key or ranges 
parameters");
-          }
-          if (fuzz != null && (splitKey != null || rangesStr != null)) {
-            throw new SolrException(
-                ErrorCode.BAD_REQUEST,
-                "fuzz can not be specified with split.key or ranges 
parameters");
-          }
-
-          Map<String, Object> map =
-              copy(
-                  req.getParams(),
-                  null,
-                  COLLECTION_PROP,
-                  SHARD_ID_PROP,
-                  "split.key",
-                  CoreAdminParams.RANGES,
-                  WAIT_FOR_FINAL_STATE,
-                  TIMING,
-                  SPLIT_METHOD,
-                  NUM_SUB_SHARDS,
-                  SPLIT_FUZZ,
-                  SPLIT_BY_PREFIX,
-                  FOLLOW_ALIASES,
-                  CREATE_NODE_SET_PARAM);
-          return copyPropertiesWithPrefix(req.getParams(), map, 
PROPERTY_PREFIX);
+          SplitShard.invokeWithV1Params(h.coreContainer, req, rsp);
+          return null;
         }),
     DELETESHARD_OP(
         DELETESHARD,
@@ -1245,6 +1193,7 @@ public class CollectionsHandler extends 
RequestHandlerBase implements Permission
         MigrateReplicas.class,
         BalanceReplicas.class,
         RestoreCollection.class,
+        SplitShard.class,
         SyncShard.class,
         CollectionProperty.class,
         DeleteNode.class,
@@ -1260,7 +1209,6 @@ public class CollectionsHandler extends 
RequestHandlerBase implements Permission
   @Override
   public Collection<Api> getApis() {
     final List<Api> apis = new ArrayList<>();
-    apis.addAll(AnnotatedApi.getApis(new SplitShardAPI(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/AdminAPIBase.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/AdminAPIBase.java
index cc270e61994..8cb671cffd5 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/AdminAPIBase.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/AdminAPIBase.java
@@ -130,12 +130,19 @@ public abstract class AdminAPIBase extends JerseyResource 
{
   protected SolrResponse submitRemoteMessageAndHandleException(
       SolrJerseyResponse response, AdminCmdContext adminCmdContext, 
ZkNodeProps remoteMessage)
       throws Exception {
+    return submitRemoteMessageAndHandleException(
+        response, adminCmdContext, remoteMessage, 
DEFAULT_COLLECTION_OP_TIMEOUT);
+  }
+
+  protected SolrResponse submitRemoteMessageAndHandleException(
+      SolrJerseyResponse response,
+      AdminCmdContext adminCmdContext,
+      ZkNodeProps remoteMessage,
+      long timeoutMs)
+      throws Exception {
     final SolrResponse remoteResponse =
         CollectionsHandler.submitCollectionApiCommand(
-            coreContainer.getZkController(),
-            adminCmdContext,
-            remoteMessage,
-            DEFAULT_COLLECTION_OP_TIMEOUT);
+            coreContainer.getZkController(), adminCmdContext, remoteMessage, 
timeoutMs);
     if (remoteResponse.getException() != null) {
       throw remoteResponse.getException();
     }
@@ -157,9 +164,23 @@ public abstract class AdminAPIBase extends JerseyResource {
       ZkNodeProps remoteMessage,
       String asyncId)
       throws Exception {
+    return submitRemoteMessageAndHandleAsync(
+        response, action, remoteMessage, asyncId, 
DEFAULT_COLLECTION_OP_TIMEOUT);
+  }
+
+  protected SolrResponse submitRemoteMessageAndHandleAsync(
+      AsyncJerseyResponse response,
+      CollectionParams.CollectionAction action,
+      ZkNodeProps remoteMessage,
+      String asyncId,
+      long timeoutMs)
+      throws Exception {
     var remoteResponse =
         submitRemoteMessageAndHandleException(
-            response, new AdminCmdContext(action, asyncId, callingLockId), 
remoteMessage);
+            response,
+            new AdminCmdContext(action, asyncId, callingLockId),
+            remoteMessage,
+            timeoutMs);
 
     if (asyncId != null) {
       response.requestId = asyncId;
@@ -174,8 +195,19 @@ public abstract class AdminAPIBase extends JerseyResource {
       ZkNodeProps remoteMessage,
       String asyncId)
       throws Exception {
+    return submitRemoteMessageAndHandleResponse(
+        response, action, remoteMessage, asyncId, 
DEFAULT_COLLECTION_OP_TIMEOUT);
+  }
+
+  protected SolrResponse submitRemoteMessageAndHandleResponse(
+      SubResponseAccumulatingJerseyResponse response,
+      CollectionParams.CollectionAction action,
+      ZkNodeProps remoteMessage,
+      String asyncId,
+      long timeoutMs)
+      throws Exception {
     var remoteResponse =
-        submitRemoteMessageAndHandleAsync(response, action, remoteMessage, 
asyncId);
+        submitRemoteMessageAndHandleAsync(response, action, remoteMessage, 
asyncId, timeoutMs);
 
     if (asyncId != null) {
       response.requestId = asyncId;
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/SplitShard.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/SplitShard.java
new file mode 100644
index 00000000000..cb483dfd262
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/SplitShard.java
@@ -0,0 +1,213 @@
+/*
+ * 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.common.cloud.ZkStateReader.COLLECTION_PROP;
+import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
+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.CommonAdminParams.ASYNC;
+import static org.apache.solr.common.params.CommonAdminParams.NUM_SUB_SHARDS;
+import static org.apache.solr.common.params.CommonAdminParams.SPLIT_BY_PREFIX;
+import static org.apache.solr.common.params.CommonAdminParams.SPLIT_FUZZ;
+import static org.apache.solr.common.params.CommonAdminParams.SPLIT_KEY;
+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.TIMING;
+import static 
org.apache.solr.handler.admin.CollectionsHandler.DEFAULT_COLLECTION_OP_TIMEOUT;
+import static 
org.apache.solr.handler.admin.api.CreateCollection.copyPrefixedPropertiesWithoutPrefix;
+import static org.apache.solr.handler.api.V2ApiUtils.flattenMapWithPrefix;
+import static 
org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
+
+import jakarta.inject.Inject;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.solr.client.api.endpoint.SplitShardApi;
+import org.apache.solr.client.api.model.SplitShardRequestBody;
+import org.apache.solr.client.api.model.SplitShardResponse;
+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.NamedList;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+
+/**
+ * V2 API for splitting an existing shard into multiple pieces.
+ *
+ * <p>This API (POST /v2/collections/collectionName/shards/shardName/split) is 
analogous to the v1
+ * /admin/collections?action=SPLITSHARD command.
+ */
+public class SplitShard extends AdminAPIBase implements SplitShardApi {
+
+  @Inject
+  public SplitShard(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse) {
+    super(coreContainer, solrQueryRequest, solrQueryResponse);
+  }
+
+  @Override
+  @PermissionName(COLL_EDIT_PERM)
+  public SplitShardResponse splitShard(
+      String collectionName, String shardName, SplitShardRequestBody 
requestBody) throws Exception {
+    final var response = instantiateJerseyResponse(SplitShardResponse.class);
+    fetchAndValidateZooKeeperAwareCoreContainer();
+    ensureRequiredParameterProvided(COLLECTION_PROP, collectionName);
+    ensureRequiredParameterProvided(SHARD_ID_PROP, shardName);
+    recordCollectionForLogAndTracing(collectionName, solrQueryRequest);
+    if (requestBody == null) {
+      requestBody = new SplitShardRequestBody();
+    }
+    validateSplitOptions(requestBody);
+    return submitSplitMessage(response, collectionName, shardName, 
requestBody);
+  }
+
+  /**
+   * The v1 form of split-by-key: the request names a collection and a split 
key but no shard, so
+   * there is no shard path parameter to populate. The overseer resolves the 
shard from the key.
+   */
+  private SplitShardResponse splitShardByKey(
+      String collectionName, SplitShardRequestBody requestBody) throws 
Exception {
+    final var response = instantiateJerseyResponse(SplitShardResponse.class);
+    fetchAndValidateZooKeeperAwareCoreContainer();
+    ensureRequiredParameterProvided(COLLECTION_PROP, collectionName);
+    recordCollectionForLogAndTracing(collectionName, solrQueryRequest);
+    validateSplitOptions(requestBody);
+    return submitSplitMessage(response, collectionName, null, requestBody);
+  }
+
+  private SplitShardResponse submitSplitMessage(
+      SplitShardResponse response,
+      String collectionName,
+      String shardName,
+      SplitShardRequestBody requestBody)
+      throws Exception {
+    final Map<String, Object> messageProps = new HashMap<>();
+    messageProps.put(COLLECTION_PROP, collectionName);
+    insertIfNotNull(messageProps, SHARD_ID_PROP, shardName);
+    insertIfNotNull(messageProps, "ranges", requestBody.ranges);
+    insertIfNotNull(messageProps, SPLIT_KEY, requestBody.splitKey);
+    insertIfNotNull(messageProps, "numSubShards", requestBody.numSubShards);
+    insertIfNotNull(messageProps, "splitFuzz", requestBody.splitFuzz);
+    insertIfNotNull(messageProps, "timing", requestBody.timing);
+    insertIfNotNull(messageProps, "splitByPrefix", requestBody.splitByPrefix);
+    insertIfNotNull(messageProps, "followAliases", requestBody.followAliases);
+    insertIfNotNull(messageProps, "splitMethod", requestBody.splitMethod);
+    insertIfNotNull(messageProps, "waitForFinalState", 
requestBody.waitForFinalState);
+    insertIfNotNull(messageProps, CREATE_NODE_SET_PARAM, 
requestBody.createNodeSet);
+    if (requestBody.coreProperties != null) {
+      flattenMapWithPrefix(requestBody.coreProperties, messageProps, 
PROPERTY_PREFIX);
+    }
+
+    final var remoteResponse =
+        submitRemoteMessageAndHandleResponse(
+            response,
+            CollectionParams.CollectionAction.SPLITSHARD,
+            new ZkNodeProps(messageProps),
+            requestBody.async,
+            DEFAULT_COLLECTION_OP_TIMEOUT * 5);
+    final var timing = (NamedList<?>) remoteResponse.getResponse().get(TIMING);
+    if (timing != null) {
+      response.timing = new LinkedHashMap<>();
+      timing.forEach(response.timing::put);
+    }
+    return response;
+  }
+
+  public static SplitShardRequestBody createRequestBodyFromV1Params(SolrParams 
params) {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.ranges = params.get(CoreAdminParams.RANGES);
+    requestBody.splitKey = params.get(SPLIT_KEY);
+    requestBody.numSubShards = params.getInt(NUM_SUB_SHARDS);
+    requestBody.splitFuzz = params.get(SPLIT_FUZZ);
+    requestBody.timing = params.getBool(TIMING);
+    requestBody.splitByPrefix = params.getBool(SPLIT_BY_PREFIX);
+    requestBody.followAliases = params.getBool(FOLLOW_ALIASES);
+    requestBody.splitMethod = params.get(SPLIT_METHOD);
+    requestBody.waitForFinalState = params.getBool(WAIT_FOR_FINAL_STATE);
+    requestBody.createNodeSet = params.get(CREATE_NODE_SET_PARAM);
+    final Map<String, String> coreProperties =
+        copyPrefixedPropertiesWithoutPrefix(params, new HashMap<>(), 
PROPERTY_PREFIX);
+    if (!coreProperties.isEmpty()) {
+      requestBody.coreProperties = new HashMap<>(coreProperties);
+    }
+    // The v1 'async' parameter becomes the submission's async id: a delegated 
operation
+    // returns no message map, so the v1 handler submits nothing itself.
+    requestBody.async = params.get(ASYNC);
+    return requestBody;
+  }
+
+  public static void invokeWithV1Params(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse)
+      throws Exception {
+    final var v1Params = solrQueryRequest.getParams();
+    final var collectionName = v1Params.required().get(COLLECTION_PROP);
+    final var shardName = v1Params.get(SHARD_ID_PROP);
+    final var requestBody = createRequestBodyFromV1Params(v1Params);
+    if (shardName == null && requestBody.splitKey == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "At least one of shard, or split.key should be specified.");
+    }
+    if (shardName != null && requestBody.splitKey != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Only one of 'shard' or 'split.key' should be specified");
+    }
+
+    final var splitShardApi = new SplitShard(coreContainer, solrQueryRequest, 
solrQueryResponse);
+    final SplitShardResponse response;
+    if (shardName != null) {
+      response = splitShardApi.splitShard(collectionName, shardName, 
requestBody);
+    } else {
+      response = splitShardApi.splitShardByKey(collectionName, requestBody);
+    }
+    V2ApiUtils.squashIntoSolrResponseWithoutHeader(solrQueryResponse, 
response);
+  }
+
+  private static void validateSplitOptions(SplitShardRequestBody requestBody) {
+    if (requestBody.splitKey != null && requestBody.ranges != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Only one of 'ranges' or 'split.key' should be specified");
+    }
+    if (requestBody.numSubShards != null
+        && (requestBody.splitKey != null || requestBody.ranges != null)) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "numSubShards can not be specified with split.key or ranges 
parameters");
+    }
+    if (requestBody.splitFuzz != null
+        && (requestBody.splitKey != null || requestBody.ranges != null)) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "fuzz can not be specified with split.key or ranges parameters");
+    }
+  }
+}
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/SplitShardAPI.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/SplitShardAPI.java
deleted file mode 100644
index 873bfe3393a..00000000000
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/SplitShardAPI.java
+++ /dev/null
@@ -1,74 +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.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.SplitShardPayload;
-import org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.common.params.CommonAdminParams;
-import org.apache.solr.common.util.StrUtils;
-import org.apache.solr.common.util.Utils;
-import org.apache.solr.handler.admin.CollectionsHandler;
-
-/**
- * V2 API for splitting an existing shard up into multiple pieces.
- *
- * <p>This API (POST /v2/collections/collectionName/shards {'split': {...}}) 
is analogous to the v1
- * /admin/collections?action=SPLITSHARD command.
- *
- * @see SplitShardPayload
- */
-@EndPoint(
-    path = {"/c/{collection}/shards", "/collections/{collection}/shards"},
-    method = POST,
-    permission = COLL_EDIT_PERM)
-public class SplitShardAPI {
-  private static final String V2_SPLIT_CMD = "split";
-
-  private final CollectionsHandler collectionsHandler;
-
-  public SplitShardAPI(CollectionsHandler collectionsHandler) {
-    this.collectionsHandler = collectionsHandler;
-  }
-
-  @Command(name = V2_SPLIT_CMD)
-  public void splitShard(PayloadObj<SplitShardPayload> obj) throws Exception {
-    final SplitShardPayload v2Body = obj.get();
-    final Map<String, Object> v1Params = Utils.convertToMap(v2Body, new 
HashMap<>());
-    v1Params.put(ACTION, 
CollectionParams.CollectionAction.SPLITSHARD.toLower());
-    v1Params.put(COLLECTION, 
obj.getRequest().getPathTemplateValues().get(COLLECTION));
-
-    if (StrUtils.isNotNullOrEmpty(v2Body.splitKey)) {
-      v1Params.put(CommonAdminParams.SPLIT_KEY, v2Body.splitKey);
-    }
-    flattenMapWithPrefix(v2Body.coreProperties, v1Params, PROPERTY_PREFIX);
-    collectionsHandler.handleRequestBody(wrapParams(obj.getRequest(), 
v1Params), obj.getResponse());
-  }
-}
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 e581c574016..2761017c68c 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
@@ -94,18 +94,14 @@ public class TestApiFramework extends SolrTestCaseJ4 {
     coreHandlers.put("/admin/ping", new PingRequestHandler());
 
     Map<String, String> parts = new HashMap<>();
-    String fullPath = "/collections/hello/shards";
+    String fullPath = "/collections/hello";
     Api api = V2HttpCall.getApiInfo(containerHandlers, fullPath, "POST", 
fullPath, parts);
     assertNotNull(api);
     assertEquals("hello", parts.get("collection"));
 
     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));
-
-    parts = new HashMap<>();
-    api = V2HttpCall.getApiInfo(containerHandlers, "/collections/hello", 
"POST", null, parts);
+    api = V2HttpCall.getApiInfo(containerHandlers, fullPath, "POST", null, 
parts);
+    assertNotNull(api);
     assertConditions(api.getSpec(), Map.of("/methods[0]", "POST", 
"/commands/modify", NOT_NULL));
     assertEquals("hello", parts.get("collection"));
 
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 99828d971a0..6c7762f4335 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
@@ -87,20 +87,6 @@ public class TestCollectionAPIs extends SolrTestCaseJ4 {
       apiBag.registerObject(clusterAPI.commands);
     }
 
-    compareOutput(
-        apiBag,
-        "/collections/collName/shards",
-        POST,
-        "{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,
-        "{split:{ splitKey:id12345, coreProperties : {prop1:prop1Val, 
prop2:prop2Val} }}",
-        "{collection: collName , split.key : id12345 , operation : splitshard, 
property.prop1:prop1Val, property.prop2: prop2Val}");
-
     compareOutput(
         apiBag,
         "/collections/coll1",
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/SplitShardAPITest.java 
b/solr/core/src/test/org/apache/solr/handler/admin/api/SplitShardAPITest.java
new file mode 100644
index 00000000000..406f9b7fbbd
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/SplitShardAPITest.java
@@ -0,0 +1,139 @@
+/*
+ * 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 java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.ShardsApi;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.cloud.Slice;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Test cases for the v2 {@link SplitShard} API, driven through the generated 
SolrJ client. */
+public class SplitShardAPITest extends SolrCloudTestCase {
+
+  @BeforeClass
+  public static void setupCluster() throws Exception {
+    configureCluster(2).addConfig("conf", 
configset("cloud-minimal")).configure();
+  }
+
+  @Test
+  public void testSplitShard() throws Exception {
+    final String collection = "splitShardAPITestColl";
+    CollectionAdminRequest.createCollection(collection, "conf", 1, 1)
+        .process(cluster.getSolrClient());
+    cluster.waitForActiveCollection(collection, 1, 1);
+
+    final ShardsApi.SplitShard splitRequest = new 
ShardsApi.SplitShard(collection, "shard1");
+    splitRequest.setNumSubShards(2);
+    splitRequest.setTiming(true);
+    final var response = splitRequest.process(cluster.getSolrClient());
+    assertEquals(0, response.responseHeader.status);
+    assertNotNull(response.timing);
+    assertTrue(response.timing.get("time") instanceof Number);
+    assertTrue(response.timing.get("splitParentCore") instanceof Map<?, ?>);
+    assertTrue(((Map<?, ?>) 
response.timing.get("splitParentCore")).get("time") instanceof Number);
+
+    waitForState(
+        "Expected sub-shards shard1_0 and shard1_1 to be active",
+        collection,
+        c -> c != null && isActive(c.getSlice("shard1_0")) && 
isActive(c.getSlice("shard1_1")));
+  }
+
+  @Test
+  public void testSplitShardWithRanges() throws Exception {
+    final String collection = "splitShardRangesTestColl";
+    CollectionAdminRequest.createCollection(collection, "conf", 1, 1)
+        .process(cluster.getSolrClient());
+    cluster.waitForActiveCollection(collection, 1, 1);
+
+    // An uneven split of the parent range, so the sub-shard ranges can only 
match if the
+    // 'ranges' value reached the split command unchanged.
+    final ShardsApi.SplitShard splitRequest = new 
ShardsApi.SplitShard(collection, "shard1");
+    splitRequest.setRanges("80000000-bfffffff,c0000000-7fffffff");
+    assertEquals(0, 
splitRequest.process(cluster.getSolrClient()).responseHeader.status);
+
+    waitForState(
+        "Expected sub-shards shard1_0 and shard1_1 to be active",
+        collection,
+        c -> c != null && isActive(c.getSlice("shard1_0")) && 
isActive(c.getSlice("shard1_1")));
+
+    final var docCollection = getCollectionState(collection);
+    assertEquals(0x80000000, 
docCollection.getSlice("shard1_0").getRange().min);
+    assertEquals(0xbfffffff, 
docCollection.getSlice("shard1_0").getRange().max);
+    assertEquals(0xc0000000, 
docCollection.getSlice("shard1_1").getRange().min);
+    assertEquals(0x7fffffff, 
docCollection.getSlice("shard1_1").getRange().max);
+  }
+
+  @Test
+  public void testSplitShardAsync() throws Exception {
+    final String collection = "splitShardAsyncTestColl";
+    CollectionAdminRequest.createCollection(collection, "conf", 1, 1)
+        .process(cluster.getSolrClient());
+    cluster.waitForActiveCollection(collection, 1, 1);
+
+    final ShardsApi.SplitShard splitRequest = new 
ShardsApi.SplitShard(collection, "shard1");
+    splitRequest.setNumSubShards(2);
+    splitRequest.setAsync("splitShardAsyncTestId");
+    final var response = splitRequest.process(cluster.getSolrClient());
+    assertEquals(0, response.responseHeader.status);
+    assertEquals("splitShardAsyncTestId", response.requestId);
+
+    waitForState(
+        "Expected sub-shards shard1_0 and shard1_1 to be active",
+        collection,
+        c -> c != null && isActive(c.getSlice("shard1_0")) && 
isActive(c.getSlice("shard1_1")));
+  }
+
+  @Test
+  public void testRejectsConflictingSplitOptions() throws Exception {
+    final String collection = "splitShardConflictingOptionsTestColl";
+    CollectionAdminRequest.createCollection(collection, "conf", 1, 1)
+        .process(cluster.getSolrClient());
+    cluster.waitForActiveCollection(collection, 1, 1);
+
+    final ShardsApi.SplitShard splitRequest = new 
ShardsApi.SplitShard(collection, "shard1");
+    splitRequest.setRanges("80000000-bfffffff,c0000000-7fffffff");
+    splitRequest.setNumSubShards(3);
+
+    final RemoteSolrException exception =
+        expectThrows(
+            RemoteSolrException.class, () -> 
splitRequest.process(cluster.getSolrClient()));
+    assertEquals(400, exception.code());
+    assertTrue(
+        exception
+            .getMessage()
+            .contains("numSubShards can not be specified with split.key or 
ranges"));
+  }
+
+  @Test
+  public void testSplitMissingCollectionThrows() {
+    final ShardsApi.SplitShard splitRequest =
+        new ShardsApi.SplitShard("missingCollection", "shard1");
+    final RemoteSolrException ex =
+        expectThrows(
+            RemoteSolrException.class, () -> 
splitRequest.process(cluster.getSolrClient()));
+    assertTrue("Expected an error status, got " + ex.code(), ex.code() >= 400);
+  }
+
+  private static boolean isActive(Slice slice) {
+    return slice != null && slice.getState() == Slice.State.ACTIVE;
+  }
+}
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/SplitShardMessageMappingTest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/api/SplitShardMessageMappingTest.java
new file mode 100644
index 00000000000..74dd6527271
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/SplitShardMessageMappingTest.java
@@ -0,0 +1,390 @@
+/*
+ * 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.common.cloud.ZkStateReader.COLLECTION_PROP;
+import static org.apache.solr.common.cloud.ZkStateReader.SHARD_ID_PROP;
+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.CommonAdminParams.NUM_SUB_SHARDS;
+import static org.apache.solr.common.params.CommonAdminParams.SPLIT_BY_PREFIX;
+import static org.apache.solr.common.params.CommonAdminParams.SPLIT_FUZZ;
+import static org.apache.solr.common.params.CommonAdminParams.SPLIT_KEY;
+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.TIMING;
+import static org.apache.solr.common.params.CoreAdminParams.RANGES;
+import static 
org.apache.solr.handler.admin.CollectionsHandler.DEFAULT_COLLECTION_OP_TIMEOUT;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.Map;
+import org.apache.solr.client.api.model.SplitShardRequestBody;
+import org.apache.solr.client.api.model.SubResponseAccumulatingJerseyResponse;
+import org.apache.solr.cloud.OverseerSolrResponse;
+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.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for the {@link SplitShard} JAX-RS resource: every {@link 
SplitShardRequestBody} field
+ * must reach the overseer message (or, for 'async', the submission's async 
id) under the name the
+ * v1 SPLITSHARD command reads.
+ *
+ * <p>Mutually exclusive split options are exercised separately.
+ */
+public class SplitShardMessageMappingTest extends MockV2APITest {
+
+  private SplitShard api;
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+    when(mockCoreContainer.isZooKeeperAware()).thenReturn(true);
+
+    api = new SplitShard(mockCoreContainer, mockQueryRequest, queryResponse);
+  }
+
+  @Test
+  public void testSplitShardCommonProperties() throws Exception {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.numSubShards = 3;
+    requestBody.splitFuzz = "0.1";
+    requestBody.timing = true;
+    requestBody.splitByPrefix = false;
+    requestBody.followAliases = true;
+    requestBody.splitMethod = "rewrite";
+    requestBody.createNodeSet = "node1:8983_solr,node2:8983_solr";
+    requestBody.coreProperties = Map.of("foo", "foo1", "bar", "bar1");
+    requestBody.async = "some_async_id";
+    requestBody.waitForFinalState = true;
+
+    api.splitShard("collName", "shard1", requestBody);
+
+    validateRunCommand(
+        CollectionParams.CollectionAction.SPLITSHARD,
+        "some_async_id",
+        message -> {
+          assertEquals(12, message.size());
+          assertEquals("collName", message.get(COLLECTION_PROP));
+          assertEquals("shard1", message.get(SHARD_ID_PROP));
+          assertEquals(3, message.get(NUM_SUB_SHARDS));
+          assertEquals("0.1", message.get(SPLIT_FUZZ));
+          assertEquals(Boolean.TRUE, message.get(TIMING));
+          assertEquals(Boolean.FALSE, message.get(SPLIT_BY_PREFIX));
+          assertEquals(Boolean.TRUE, message.get(FOLLOW_ALIASES));
+          assertEquals("rewrite", message.get(SPLIT_METHOD));
+          assertEquals("node1:8983_solr,node2:8983_solr", 
message.get(CREATE_NODE_SET_PARAM));
+          assertEquals(Boolean.TRUE, message.get(WAIT_FOR_FINAL_STATE));
+          assertEquals("foo1", message.get("property.foo"));
+          assertEquals("bar1", message.get("property.bar"));
+        });
+  }
+
+  @Test
+  public void testSplitShardWithRanges() throws Exception {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.ranges = "80000000-bfffffff,c0000000-7fffffff";
+
+    api.splitShard("collName", "shard1", requestBody);
+
+    validateRunCommand(
+        CollectionParams.CollectionAction.SPLITSHARD,
+        message -> {
+          assertEquals(3, message.size());
+          assertEquals(requestBody.ranges, message.get(RANGES));
+        });
+  }
+
+  @Test
+  public void testSplitShardWithSplitKey() throws Exception {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.splitKey = "someKey!";
+    requestBody.splitByPrefix = true;
+
+    api.splitShard("collName", "shard1", requestBody);
+
+    validateRunCommand(
+        CollectionParams.CollectionAction.SPLITSHARD,
+        message -> {
+          assertEquals(4, message.size());
+          assertEquals(requestBody.splitKey, message.get(SPLIT_KEY));
+          assertEquals(Boolean.TRUE, message.get(SPLIT_BY_PREFIX));
+        });
+  }
+
+  @Test
+  public void testSplitShardUsesExtendedTimeout() throws Exception {
+    api.splitShard("collName", "shard1", new SplitShardRequestBody());
+
+    verify(mockCommandRunner)
+        .runCollectionCommand(any(), any(), eq(DEFAULT_COLLECTION_OP_TIMEOUT * 
5));
+  }
+
+  @Test
+  public void testOtherCommandsRetainDefaultTimeout() throws Exception {
+    api.submitRemoteMessageAndHandleResponse(
+        new SubResponseAccumulatingJerseyResponse(),
+        CollectionParams.CollectionAction.CREATESHARD,
+        new ZkNodeProps(Map.of(COLLECTION_PROP, "collName", SHARD_ID_PROP, 
"shard2")),
+        null);
+
+    verify(mockCommandRunner).runCollectionCommand(any(), any(), 
eq(DEFAULT_COLLECTION_OP_TIMEOUT));
+  }
+
+  @Test
+  public void testSplitShardPreservesTimingAndSubResponses() throws Exception {
+    final var phaseTiming = new NamedList<Object>();
+    phaseTiming.add("time", 7D);
+    final var timing = new NamedList<Object>();
+    timing.add("time", 12D);
+    timing.add("split", phaseTiming);
+    final var success = Map.of("node1", Map.of("status", 0));
+    final var remoteResponse = new NamedList<Object>();
+    remoteResponse.add(TIMING, timing);
+    remoteResponse.add("success", success);
+    remoteResponse.add("warning", "some warning");
+    when(mockCommandRunner.runCollectionCommand(any(), any(), anyLong()))
+        .thenReturn(new OverseerSolrResponse(remoteResponse));
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.timing = true;
+
+    final var response = api.splitShard("collName", "shard1", requestBody);
+
+    assertNotNull(response.timing);
+    assertEquals(Double.valueOf(12D), response.timing.get("time"));
+    assertSame(phaseTiming, response.timing.get("split"));
+    assertEquals(success, response.successfulSubResponsesByNodeName);
+    assertEquals("some warning", response.warning);
+  }
+
+  @Test
+  public void testSplitShardOmitsUnrequestedTiming() throws Exception {
+    final var response = api.splitShard("collName", "shard1", new 
SplitShardRequestBody());
+
+    assertNull(response.timing);
+  }
+
+  @Test
+  public void testRejectsRangesAndSplitKey() {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.ranges = "80000000-bfffffff,c0000000-7fffffff";
+    requestBody.splitKey = "someKey!";
+
+    assertRejectedSplit(requestBody, "Only one of 'ranges' or 'split.key' 
should be specified");
+  }
+
+  @Test
+  public void testRejectsNumSubShardsAndRanges() {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.ranges = "80000000-bfffffff,c0000000-7fffffff";
+    requestBody.numSubShards = 3;
+
+    assertRejectedSplit(
+        requestBody, "numSubShards can not be specified with split.key or 
ranges parameters");
+  }
+
+  @Test
+  public void testRejectsNumSubShardsAndSplitKey() {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.splitKey = "someKey!";
+    requestBody.numSubShards = 3;
+
+    assertRejectedSplit(
+        requestBody, "numSubShards can not be specified with split.key or 
ranges parameters");
+  }
+
+  @Test
+  public void testRejectsSplitFuzzAndRanges() {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.ranges = "80000000-bfffffff,c0000000-7fffffff";
+    requestBody.splitFuzz = "0.1";
+
+    assertRejectedSplit(
+        requestBody, "fuzz can not be specified with split.key or ranges 
parameters");
+  }
+
+  @Test
+  public void testRejectsSplitFuzzAndSplitKey() {
+    final var requestBody = new SplitShardRequestBody();
+    requestBody.splitKey = "someKey!";
+    requestBody.splitFuzz = "0.1";
+
+    assertRejectedSplit(
+        requestBody, "fuzz can not be specified with split.key or ranges 
parameters");
+  }
+
+  private void assertRejectedSplit(SplitShardRequestBody requestBody, String 
expectedMessage) {
+    final SolrException exception =
+        expectThrows(SolrException.class, () -> api.splitShard("collName", 
"shard1", requestBody));
+
+    assertEquals(400, exception.code());
+    assertEquals(expectedMessage, exception.getMessage());
+    verifyNoInteractions(mockCommandRunner);
+  }
+
+  @Test
+  public void testSplitShardWithEmptyBody() throws Exception {
+    api.splitShard("collName", "shard1", new SplitShardRequestBody());
+
+    validateRunCommand(
+        CollectionParams.CollectionAction.SPLITSHARD,
+        message -> {
+          assertEquals(2, message.size());
+          assertEquals("collName", message.get(COLLECTION_PROP));
+          assertEquals("shard1", message.get(SHARD_ID_PROP));
+        });
+  }
+
+  @Test
+  public void testSplitShardWithNullBody() throws Exception {
+    api.splitShard("collName", "shard1", null);
+
+    validateRunCommand(
+        CollectionParams.CollectionAction.SPLITSHARD,
+        message -> {
+          assertEquals(2, message.size());
+          assertEquals("collName", message.get(COLLECTION_PROP));
+          assertEquals("shard1", message.get(SHARD_ID_PROP));
+        });
+  }
+
+  @Test
+  public void testCreateRequestBodyFromV1Params() {
+    final var v1Params = new ModifiableSolrParams();
+    v1Params.set(RANGES, "80000000-bfffffff,c0000000-7fffffff");
+    v1Params.set(NUM_SUB_SHARDS, "3");
+    v1Params.set(SPLIT_FUZZ, "0.1");
+    v1Params.set(TIMING, "true");
+    v1Params.set(SPLIT_BY_PREFIX, "true");
+    v1Params.set(FOLLOW_ALIASES, "true");
+    v1Params.set(SPLIT_METHOD, "rewrite");
+    v1Params.set(CREATE_NODE_SET_PARAM, "node1:8983_solr,node2:8983_solr");
+    v1Params.set(WAIT_FOR_FINAL_STATE, "true");
+    v1Params.set("property.foo", "foo1");
+    v1Params.set("async", "v1AsyncId");
+
+    final var requestBody = SplitShard.createRequestBodyFromV1Params(v1Params);
+
+    assertEquals("80000000-bfffffff,c0000000-7fffffff", requestBody.ranges);
+    assertEquals(Integer.valueOf(3), requestBody.numSubShards);
+    assertEquals("0.1", requestBody.splitFuzz);
+    assertEquals(Boolean.TRUE, requestBody.timing);
+    assertEquals(Boolean.TRUE, requestBody.splitByPrefix);
+    assertEquals(Boolean.TRUE, requestBody.followAliases);
+    assertEquals("rewrite", requestBody.splitMethod);
+    assertEquals("node1:8983_solr,node2:8983_solr", requestBody.createNodeSet);
+    assertEquals(Boolean.TRUE, requestBody.waitForFinalState);
+    assertEquals(Map.of("foo", "foo1"), requestBody.coreProperties);
+    // The v1 'async' parameter becomes the request body's 'async' field: a 
delegated
+    // operation returns no message map, so the v1 handler submits nothing 
itself and
+    // the resource's submission carries the async id.
+    assertEquals("v1AsyncId", requestBody.async);
+  }
+
+  @Test
+  public void testInvokeWithV1ParamsShardPath() throws Exception {
+    final var v1Params = new ModifiableSolrParams();
+    v1Params.set(COLLECTION_PROP, "collName");
+    v1Params.set(SHARD_ID_PROP, "shard1");
+    v1Params.set(NUM_SUB_SHARDS, "3");
+    v1Params.set(CREATE_NODE_SET_PARAM, "node1:8983_solr,node2:8983_solr");
+    v1Params.set("property.foo", "foo1");
+    v1Params.set("async", "v1AsyncId");
+    when(mockQueryRequest.getParams()).thenReturn(v1Params);
+
+    SplitShard.invokeWithV1Params(mockCoreContainer, mockQueryRequest, 
queryResponse);
+
+    validateRunCommand(
+        CollectionParams.CollectionAction.SPLITSHARD,
+        "v1AsyncId",
+        message -> {
+          assertEquals(5, message.size());
+          assertEquals("collName", message.get(COLLECTION_PROP));
+          assertEquals("shard1", message.get(SHARD_ID_PROP));
+          assertEquals(3, message.get(NUM_SUB_SHARDS));
+          assertEquals("node1:8983_solr,node2:8983_solr", 
message.get(CREATE_NODE_SET_PARAM));
+          assertEquals("foo1", message.get("property.foo"));
+        });
+  }
+
+  @Test
+  public void testInvokeWithV1ParamsSplitKeyOnly() throws Exception {
+    final var v1Params = new ModifiableSolrParams();
+    v1Params.set(COLLECTION_PROP, "collName");
+    v1Params.set(SPLIT_KEY, "someKey!");
+    when(mockQueryRequest.getParams()).thenReturn(v1Params);
+
+    SplitShard.invokeWithV1Params(mockCoreContainer, mockQueryRequest, 
queryResponse);
+
+    validateRunCommand(
+        CollectionParams.CollectionAction.SPLITSHARD,
+        message -> {
+          assertEquals(2, message.size());
+          assertEquals("collName", message.get(COLLECTION_PROP));
+          assertEquals("someKey!", message.get(SPLIT_KEY));
+          assertFalse(message.containsKey(SHARD_ID_PROP));
+        });
+  }
+
+  @Test
+  public void testInvokeWithV1ParamsRejectsShardAndSplitKey() {
+    final var v1Params = new ModifiableSolrParams();
+    v1Params.set(COLLECTION_PROP, "collName");
+    v1Params.set(SHARD_ID_PROP, "shard1");
+    v1Params.set(SPLIT_KEY, "someKey!");
+    when(mockQueryRequest.getParams()).thenReturn(v1Params);
+
+    final SolrException exception =
+        expectThrows(
+            SolrException.class,
+            () ->
+                SplitShard.invokeWithV1Params(mockCoreContainer, 
mockQueryRequest, queryResponse));
+
+    assertEquals(400, exception.code());
+    assertEquals("Only one of 'shard' or 'split.key' should be specified", 
exception.getMessage());
+    verifyNoInteractions(mockCommandRunner);
+  }
+
+  @Test
+  public void testInvokeWithV1ParamsRejectsNeitherShardNorSplitKey() {
+    final var v1Params = new ModifiableSolrParams();
+    v1Params.set(COLLECTION_PROP, "collName");
+    when(mockQueryRequest.getParams()).thenReturn(v1Params);
+
+    final SolrException exception =
+        expectThrows(
+            SolrException.class,
+            () ->
+                SplitShard.invokeWithV1Params(mockCoreContainer, 
mockQueryRequest, queryResponse));
+
+    assertEquals(400, exception.code());
+    assertEquals(
+        "At least one of shard, or split.key should be specified.", 
exception.getMessage());
+    verifyNoInteractions(mockCommandRunner);
+  }
+}
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
deleted file mode 100644
index 9731922de46..00000000000
--- 
a/solr/core/src/test/org/apache/solr/handler/admin/api/V2ShardsAPIMappingTest.java
+++ /dev/null
@@ -1,106 +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.common.cloud.ZkStateReader.SHARD_ID_PROP;
-import static org.apache.solr.common.params.CollectionAdminParams.COLLECTION;
-import static 
org.apache.solr.common.params.CollectionAdminParams.FOLLOW_ALIASES;
-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;
-import static org.apache.solr.common.params.CommonAdminParams.SPLIT_FUZZ;
-import static org.apache.solr.common.params.CommonAdminParams.SPLIT_KEY;
-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 org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.common.params.CoreAdminParams;
-import org.apache.solr.common.params.SolrParams;
-import org.apache.solr.handler.admin.CollectionsHandler;
-import org.apache.solr.handler.admin.V2ApiMappingTest;
-import org.junit.Test;
-
-/**
- * Unit tests for the V2 APIs that use the /c/{collection}/shards or 
/c/{collection}/shards/{shard}
- * paths.
- *
- * <p>Note that the V2 requests made by these tests are not necessarily 
semantically valid. They
- * shouldn't be taken as examples. In several instances, mutually exclusive 
JSON parameters are
- * provided. This is done to exercise conversion of all parameters, even if 
particular combinations
- * are never expected in the same request.
- */
-public class V2ShardsAPIMappingTest extends 
V2ApiMappingTest<CollectionsHandler> {
-
-  @Override
-  public void populateApiBag() {
-    final CollectionsHandler collectionsHandler = getRequestHandler();
-    apiBag.registerObject(new SplitShardAPI(collectionsHandler));
-  }
-
-  @Override
-  public CollectionsHandler createUnderlyingRequestHandler() {
-    return createMock(CollectionsHandler.class);
-  }
-
-  @Override
-  public boolean isCoreSpecific() {
-    return false;
-  }
-
-  @Test
-  public void testSplitShardAllProperties() throws Exception {
-    final SolrParams v1Params =
-        captureConvertedV1Params(
-            "/collections/collName/shards",
-            "POST",
-            "{ 'split': {"
-                + "'shard': 'shard1', "
-                + "'ranges': 'someRangeValues', "
-                + "'splitKey': 'someSplitKey', "
-                + "'numSubShards': 123, "
-                + "'splitFuzz': 'some_fuzz_value', "
-                + "'timing': true, "
-                + "'splitByPrefix': true, "
-                + "'followAliases': true, "
-                + "'splitMethod': 'rewrite', "
-                + "'async': 'some_async_id', "
-                + "'waitForFinalState': true, "
-                + "'coreProperties': {"
-                + "    'foo': 'foo1', "
-                + "    'bar': 'bar1', "
-                + "}}}");
-
-    assertEquals(CollectionParams.CollectionAction.SPLITSHARD.lowerName, 
v1Params.get(ACTION));
-    assertEquals("collName", v1Params.get(COLLECTION));
-    assertEquals("shard1", v1Params.get(SHARD_ID_PROP));
-    assertEquals("someRangeValues", v1Params.get(CoreAdminParams.RANGES));
-    assertEquals("someSplitKey", v1Params.get(SPLIT_KEY));
-    assertEquals(123, v1Params.getPrimitiveInt(NUM_SUB_SHARDS));
-    assertEquals("some_fuzz_value", v1Params.get(SPLIT_FUZZ));
-    assertTrue(v1Params.getPrimitiveBool(TIMING));
-    assertTrue(v1Params.getPrimitiveBool(SPLIT_BY_PREFIX));
-    assertTrue(v1Params.getPrimitiveBool(FOLLOW_ALIASES));
-    assertEquals("rewrite", v1Params.get(SPLIT_METHOD));
-    assertEquals("some_async_id", v1Params.get(ASYNC));
-    assertTrue(v1Params.getPrimitiveBool(WAIT_FOR_FINAL_STATE));
-    assertEquals("foo1", v1Params.get("property.foo"));
-    assertEquals("bar1", v1Params.get("property.bar"));
-  }
-}

Reply via email to