SOLR-16659: Properly construct V2 base urls instead of replacing substring "/solr" with "/api" (#2473)

Refactoring and clean up on how to craft URLS that are V2 from V1 sources.
diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index d672ae9..56ca1a3 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -139,6 +139,8 @@
 
 * PR-2475: Fixed node listing bug in Admin UI when different hostnames start with the same front part. (@hgdharold via Eric Pugh)
 
+* SOLR-16659: Properly construct V2 base urls instead of replacing substring "/solr" with "/api" (Andrey Bozhko via Eric Pugh)
+
 Dependency Upgrades
 ---------------------
 (No changes)
diff --git a/solr/core/src/java/org/apache/solr/filestore/DistribFileStore.java b/solr/core/src/java/org/apache/solr/filestore/DistribFileStore.java
index 3cf8cbc..4c62c1f 100644
--- a/solr/core/src/java/org/apache/solr/filestore/DistribFileStore.java
+++ b/solr/core/src/java/org/apache/solr/filestore/DistribFileStore.java
@@ -175,10 +175,9 @@
 
     private boolean fetchFileFromNodeAndPersist(String fromNode) {
       log.info("fetching a file {} from {} ", path, fromNode);
-      String url =
-          coreContainer.getZkController().getZkStateReader().getBaseUrlForNodeName(fromNode);
-      if (url == null) throw new SolrException(BAD_REQUEST, "No such node");
-      String baseUrl = url.replace("/solr", "/api");
+      String baseUrl =
+          coreContainer.getZkController().getZkStateReader().getBaseUrlV2ForNodeName(fromNode);
+      if (baseUrl == null) throw new SolrException(BAD_REQUEST, "No such node");
 
       ByteBuffer metadata = null;
       Map<?, ?> m = null;
@@ -220,10 +219,9 @@
       ArrayList<String> l = coreContainer.getFileStoreAPI().shuffledNodes();
       for (String liveNode : l) {
         try {
-          String baseurl =
-              coreContainer.getZkController().getZkStateReader().getBaseUrlForNodeName(liveNode);
-          String url = baseurl.replace("/solr", "/api");
-          String reqUrl = url + "/node/files" + path + "?meta=true&wt=javabin&omitHeader=true";
+          String baseUrl =
+              coreContainer.getZkController().getZkStateReader().getBaseUrlV2ForNodeName(liveNode);
+          String reqUrl = baseUrl + "/node/files" + path + "?meta=true&wt=javabin&omitHeader=true";
           boolean nodeHasBlob = false;
           Object nl =
               Utils.executeGET(
@@ -362,8 +360,8 @@
     try {
       for (String node : nodes) {
         String baseUrl =
-            coreContainer.getZkController().getZkStateReader().getBaseUrlForNodeName(node);
-        String url = baseUrl.replace("/solr", "/api") + "/node/files" + info.path + "?getFrom=";
+            coreContainer.getZkController().getZkStateReader().getBaseUrlV2ForNodeName(node);
+        String url = baseUrl + "/node/files" + info.path + "?getFrom=";
         if (i < FETCHFROM_SRC) {
           // this is to protect very large clusters from overwhelming a single node
           // the first FETCHFROM_SRC nodes will be asked to fetch from this node.
@@ -502,8 +500,8 @@
     HttpClient client = coreContainer.getUpdateShardHandler().getDefaultHttpClient();
     for (String node : nodes) {
       String baseUrl =
-          coreContainer.getZkController().getZkStateReader().getBaseUrlForNodeName(node);
-      String url = baseUrl.replace("/solr", "/api") + "/node/files" + path;
+          coreContainer.getZkController().getZkStateReader().getBaseUrlV2ForNodeName(node);
+      String url = baseUrl + "/node/files" + path;
       HttpDelete del = new HttpDelete(url);
       // invoke delete command on all nodes asynchronously
       coreContainer.runAsync(() -> Utils.executeHttpMethod(client, url, null, del));
diff --git a/solr/core/src/java/org/apache/solr/pkg/PackageAPI.java b/solr/core/src/java/org/apache/solr/pkg/PackageAPI.java
index 6e26442..7e2c5a6 100644
--- a/solr/core/src/java/org/apache/solr/pkg/PackageAPI.java
+++ b/solr/core/src/java/org/apache/solr/pkg/PackageAPI.java
@@ -257,11 +257,7 @@
       for (String s : coreContainer.getFileStoreAPI().shuffledNodes()) {
         Utils.executeGET(
             coreContainer.getUpdateShardHandler().getDefaultHttpClient(),
-            coreContainer
-                    .getZkController()
-                    .zkStateReader
-                    .getBaseUrlForNodeName(s)
-                    .replace("/solr", "/api")
+            coreContainer.getZkController().zkStateReader.getBaseUrlV2ForNodeName(s)
                 + "/cluster/package?wt=javabin&omitHeader=true&refreshPackage="
                 + p,
             Utils.JAVABINCONSUMER);
@@ -429,11 +425,7 @@
     for (String s : coreContainer.getFileStoreAPI().shuffledNodes()) {
       Utils.executeGET(
           coreContainer.getUpdateShardHandler().getDefaultHttpClient(),
-          coreContainer
-                  .getZkController()
-                  .zkStateReader
-                  .getBaseUrlForNodeName(s)
-                  .replace("/solr", "/api")
+          coreContainer.getZkController().zkStateReader.getBaseUrlV2ForNodeName(s)
               + "/cluster/package?wt=javabin&omitHeader=true&expectedVersion"
               + expected,
           Utils.JAVABINCONSUMER);
diff --git a/solr/core/src/test/org/apache/solr/cloud/AliasIntegrationTest.java b/solr/core/src/test/org/apache/solr/cloud/AliasIntegrationTest.java
index c3a9e77..3863043 100644
--- a/solr/core/src/test/org/apache/solr/cloud/AliasIntegrationTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/AliasIntegrationTest.java
@@ -238,9 +238,8 @@
   public void testModifyPropertiesV2() throws Exception {
     final String aliasName = getSaferTestName();
     ZkStateReader zkStateReader = createColectionsAndAlias(aliasName);
-    final String baseUrl =
-        cluster.getRandomJetty(random()).getBaseUrl().toString().replace("/solr", "");
-    String aliasApi = String.format(Locale.ENGLISH, "/api/aliases/%s/properties", aliasName);
+    final String baseUrl = cluster.getRandomJetty(random()).getBaseURLV2().toString();
+    String aliasApi = String.format(Locale.ENGLISH, "/aliases/%s/properties", aliasName);
 
     HttpPut withoutBody = new HttpPut(baseUrl + aliasApi);
     assertEquals(400, httpClient.execute(withoutBody).getStatusLine().getStatusCode());
@@ -260,7 +259,7 @@
     checkFooAndBarMeta(aliasName, zkStateReader, "baz", "bam");
 
     String aliasPropertyApi =
-        String.format(Locale.ENGLISH, "/api/aliases/%s/properties/%s", aliasName, "foo");
+        String.format(Locale.ENGLISH, "/aliases/%s/properties/%s", aliasName, "foo");
     HttpPut updateByProperty = new HttpPut(baseUrl + aliasPropertyApi);
     updateByProperty.setEntity(
         new StringEntity("{ \"value\": \"zab\" }", ContentType.APPLICATION_JSON));
diff --git a/solr/core/src/test/org/apache/solr/cloud/MigrateReplicasTest.java b/solr/core/src/test/org/apache/solr/cloud/MigrateReplicasTest.java
index 0be4ca1..f96b621 100644
--- a/solr/core/src/test/org/apache/solr/cloud/MigrateReplicasTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/MigrateReplicasTest.java
@@ -344,8 +344,8 @@
     Map<?, ?> r = null;
 
     String uri =
-        cluster.getJettySolrRunners().get(0).getBaseUrl().toString().replace("/solr", "")
-            + "/api/cluster/replicas/migrate";
+        cluster.getJettySolrRunners().get(0).getBaseURLV2().toString()
+            + "/cluster/replicas/migrate";
     try {
       httpRequest = new HttpPost(uri);
 
diff --git a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
index 57a067c..a45232a 100644
--- a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
+++ b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
@@ -1581,7 +1581,7 @@
       final ByteBuffer fileBytes =
           TestSolrConfigHandler.getFileContent(file.getAbsolutePath(), false);
       final String uriEnding =
-          "/api/cluster/configs/"
+          "/cluster/configs/"
               + configSetName
               + suffix
               + (!overwrite ? "?overwrite=false" : "")
@@ -1590,8 +1590,7 @@
       Map<?, ?> map =
           postDataAndGetResponse(
               cluster.getSolrClient(),
-              cluster.getJettySolrRunners().get(0).getBaseUrl().toString().replace("/solr", "")
-                  + uriEnding,
+              cluster.getJettySolrRunners().get(0).getBaseURLV2().toString() + uriEnding,
               fileBytes,
               username,
               usePut);
@@ -1634,7 +1633,7 @@
       final ByteBuffer sampleConfigFile =
           TestSolrConfigHandler.getFileContent(file.getAbsolutePath(), false);
       final String uriEnding =
-          "/api/cluster/configs/"
+          "/cluster/configs/"
               + configSetName
               + suffix
               + "/"
@@ -1646,8 +1645,7 @@
       Map<?, ?> map =
           postDataAndGetResponse(
               cluster.getSolrClient(),
-              cluster.getJettySolrRunners().get(0).getBaseUrl().toString().replace("/solr", "")
-                  + uriEnding,
+              cluster.getJettySolrRunners().get(0).getBaseURLV2().toString() + uriEnding,
               sampleConfigFile,
               username,
               usePut);
diff --git a/solr/core/src/test/org/apache/solr/filestore/TestDistribFileStore.java b/solr/core/src/test/org/apache/solr/filestore/TestDistribFileStore.java
index a6fa83b..6266878 100644
--- a/solr/core/src/test/org/apache/solr/filestore/TestDistribFileStore.java
+++ b/solr/core/src/test/org/apache/solr/filestore/TestDistribFileStore.java
@@ -169,7 +169,7 @@
                     return true;
                   });
       for (JettySolrRunner jettySolrRunner : cluster.getJettySolrRunners()) {
-        String baseUrl = jettySolrRunner.getBaseUrl().toString().replace("/solr", "/api");
+        String baseUrl = jettySolrRunner.getBaseURLV2().toString();
         String url = baseUrl + "/node/files/package/mypkg/v1.0?wt=javabin";
         assertResponseValues(10, new Fetcher(url, jettySolrRunner), expected);
       }
@@ -196,7 +196,7 @@
       boolean verifyContent)
       throws Exception {
     for (JettySolrRunner jettySolrRunner : cluster.getJettySolrRunners()) {
-      String baseUrl = jettySolrRunner.getBaseUrl().toString().replace("/solr", "/api");
+      String baseUrl = jettySolrRunner.getBaseURLV2().toString();
       String url = baseUrl + "/node/files" + path + "?wt=javabin&meta=true";
       assertResponseValues(10, new Fetcher(url, jettySolrRunner), expected);
 
diff --git a/solr/core/src/test/org/apache/solr/handler/TestContainerPlugin.java b/solr/core/src/test/org/apache/solr/handler/TestContainerPlugin.java
index eabadb8..bd19c67 100644
--- a/solr/core/src/test/org/apache/solr/handler/TestContainerPlugin.java
+++ b/solr/core/src/test/org/apache/solr/handler/TestContainerPlugin.java
@@ -572,7 +572,7 @@
 
   public void waitForAllNodesToSync(String path, Map<String, Object> expected) throws Exception {
     for (JettySolrRunner jettySolrRunner : cluster.getJettySolrRunners()) {
-      String baseUrl = jettySolrRunner.getBaseUrl().toString().replace("/solr", "/api");
+      String baseUrl = jettySolrRunner.getBaseURLV2().toString();
       String url = baseUrl + path + "?wt=javabin";
       TestDistribFileStore.assertResponseValues(1, new Fetcher(url, jettySolrRunner), expected);
     }
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/ZookeeperReadAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/ZookeeperReadAPITest.java
index e77290d..39d494e 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/ZookeeperReadAPITest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/ZookeeperReadAPITest.java
@@ -52,8 +52,10 @@
     super.setUp();
 
     baseUrl = cluster.getJettySolrRunner(0).getBaseUrl();
-    basezk = baseUrl.toString().replace("/solr", "/api") + "/cluster/zookeeper/data";
-    basezkls = baseUrl.toString().replace("/solr", "/api") + "/cluster/zookeeper/children";
+
+    String baseUrlV2 = cluster.getJettySolrRunner(0).getBaseURLV2().toString();
+    basezk = baseUrlV2 + "/cluster/zookeeper/data";
+    basezkls = baseUrlV2 + "/cluster/zookeeper/children";
   }
 
   @After
diff --git a/solr/core/src/test/org/apache/solr/pkg/TestPackages.java b/solr/core/src/test/org/apache/solr/pkg/TestPackages.java
index d70aa37..aae3601 100644
--- a/solr/core/src/test/org/apache/solr/pkg/TestPackages.java
+++ b/solr/core/src/test/org/apache/solr/pkg/TestPackages.java
@@ -691,8 +691,7 @@
     // So far we have been verifying the details with  ZK directly
     // use the package read API to verify with each node that it has the correct data
     for (JettySolrRunner jetty : cluster.getJettySolrRunners()) {
-      String path =
-          jetty.getBaseUrl().toString().replace("/solr", "/api") + "/cluster/package?wt=javabin";
+      String path = jetty.getBaseURLV2().toString() + "/cluster/package?wt=javabin";
       TestDistribFileStore.assertResponseValues(
           10,
           new Callable<NavigableObject>() {
diff --git a/solr/core/src/test/org/apache/solr/security/AuditLoggerIntegrationTest.java b/solr/core/src/test/org/apache/solr/security/AuditLoggerIntegrationTest.java
index 2232ddb..47886c2 100644
--- a/solr/core/src/test/org/apache/solr/security/AuditLoggerIntegrationTest.java
+++ b/solr/core/src/test/org/apache/solr/security/AuditLoggerIntegrationTest.java
@@ -253,12 +253,11 @@
   @Test
   public void illegalAdminPathError() throws Exception {
     setupCluster(false, null, false);
-    String baseUrl = testHarness.get().cluster.getJettySolrRunner(0).getBaseUrl().toString();
+    String baseUrl = testHarness.get().cluster.getJettySolrRunner(0).getBaseURLV2().toString();
     expectThrows(
         FileNotFoundException.class,
         () -> {
-          try (InputStream is =
-              new URL(baseUrl.replace("/solr", "") + "/api/node/foo").openStream()) {
+          try (InputStream is = new URL(baseUrl + "/node/foo").openStream()) {
             new String(is.readAllBytes(), StandardCharsets.UTF_8);
           }
         });
diff --git a/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ZkStateReader.java b/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ZkStateReader.java
index 32ec0b2..b9882dd 100644
--- a/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ZkStateReader.java
+++ b/solr/solrj-zookeeper/src/java/org/apache/solr/common/cloud/ZkStateReader.java
@@ -119,7 +119,7 @@
 
   /**
    * This ZooKeeper file is no longer used starting with Solr 9 but keeping the name around to check
-   * if it is still present and non empty (in case of upgrade from previous Solr version). It used
+   * if it is still present and non-empty (in case of upgrade from previous Solr version). It used
    * to contain collection state for all collections in the cluster.
    */
   public static final String UNSUPPORTED_CLUSTER_STATE = "/clusterstate.json";
@@ -163,7 +163,6 @@
   private static final int GET_LEADER_RETRY_INTERVAL_MS = 50;
   private static final int GET_LEADER_RETRY_DEFAULT_TIMEOUT =
       Integer.parseInt(System.getProperty("zkReaderGetLeaderRetryTimeoutMs", "4000"));
-  ;
 
   public static final String LEADER_ELECT_ZKNODE = "leader_elect";
 
@@ -1331,10 +1330,24 @@
    * Returns the baseURL corresponding to a given node's nodeName -- NOTE: does not (currently)
    * imply that the nodeName (or resulting baseURL) exists in the cluster.
    *
-   * @lucene.experimental
+   * @param nodeName name of the node
+   * @return url that looks like {@code https://localhost:8983/solr}
    */
   public String getBaseUrlForNodeName(final String nodeName) {
-    return Utils.getBaseUrlForNodeName(nodeName, getClusterProperty(URL_SCHEME, "http"));
+    String urlScheme = getClusterProperty(URL_SCHEME, "http");
+    return Utils.getBaseUrlForNodeName(nodeName, urlScheme, false);
+  }
+
+  /**
+   * Returns the V2 baseURL corresponding to a given node's nodeName -- NOTE: does not (currently)
+   * imply that the nodeName (or resulting baseURL) exists in the cluster.
+   *
+   * @param nodeName name of the node
+   * @return url that looks like {@code https://localhost:8983/api}
+   */
+  public String getBaseUrlV2ForNodeName(final String nodeName) {
+    String urlScheme = getClusterProperty(URL_SCHEME, "http");
+    return Utils.getBaseUrlForNodeName(nodeName, urlScheme, true);
   }
 
   /** Watches a single collection's state.json. */
@@ -2265,7 +2278,7 @@
     }
 
     /**
-     * Ensures the internal aliases is up to date. If there is a change, return true.
+     * Ensures the internal aliases is up-to-date. If there is a change, return true.
      *
      * @return true if an update was performed
      */
@@ -2273,7 +2286,7 @@
       if (log.isDebugEnabled()) {
         log.debug("Checking ZK for most up to date Aliases {}", ALIASES);
       }
-      // Call sync() first to ensure the subsequent read (getData) is up to date.
+      // Call sync() first to ensure the subsequent read (getData) is up-to-date.
       zkClient.getZooKeeper().sync(ALIASES, null, null);
       return setIfNewer(zkClient.getNode(ALIASES, null, true));
     }
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
index 612d95b..b2a08a7 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
@@ -374,7 +374,7 @@
 
     if (request instanceof V2Request) {
       if (System.getProperty("solr.v2RealPath") == null || ((V2Request) request).isForceV2()) {
-        basePath = baseUrl.replace("/solr", "/api");
+        basePath = changeV2RequestEndpoint(baseUrl);
       } else {
         basePath = baseUrl + "/____v2";
       }
diff --git a/solr/solrj/src/java/org/apache/solr/common/util/Utils.java b/solr/solrj/src/java/org/apache/solr/common/util/Utils.java
index 4a1b39b..f37038e 100644
--- a/solr/solrj/src/java/org/apache/solr/common/util/Utils.java
+++ b/solr/solrj/src/java/org/apache/solr/common/util/Utils.java
@@ -716,10 +716,30 @@
     return (at == -1) ? (urlScheme + "://" + url) : urlScheme + url.substring(at);
   }
 
+  /**
+   * Construct a V1 base url for the Solr node, given its name (e.g., 'app-node-1:8983_solr') and a
+   * URL scheme.
+   *
+   * @param nodeName name of the Solr node
+   * @param urlScheme scheme for the base url ('http' or 'https')
+   * @return url that looks like {@code https://app-node-1:8983/solr}
+   * @throws IllegalArgumentException if the provided node name is malformed
+   */
   public static String getBaseUrlForNodeName(final String nodeName, final String urlScheme) {
     return getBaseUrlForNodeName(nodeName, urlScheme, false);
   }
 
+  /**
+   * Construct a V1 or a V2 base url for the Solr node, given its name (e.g.,
+   * 'app-node-1:8983_solr') and a URL scheme.
+   *
+   * @param nodeName name of the Solr node
+   * @param urlScheme scheme for the base url ('http' or 'https')
+   * @param isV2 whether a V2 url should be constructed
+   * @return url that looks like {@code https://app-node-1:8983/api} (V2) or {@code
+   *     https://app-node-1:8983/solr} (V1)
+   * @throws IllegalArgumentException if the provided node name is malformed
+   */
   public static String getBaseUrlForNodeName(
       final String nodeName, final String urlScheme, boolean isV2) {
     final int colonAt = nodeName.indexOf(':');