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

gerlowskija 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 730398a940c SOLR-16738: Refactor AdminHandlersProxy for better 
extensibility  (#3991)
730398a940c is described below

commit 730398a940c3287a157590d7b899bd3591d4f516
Author: Jason Gerlowski <[email protected]>
AuthorDate: Mon Apr 27 11:44:35 2026 -0400

    SOLR-16738: Refactor AdminHandlersProxy for better extensibility  (#3991)
    
    Replaces the static AdminHandlersProxy utility with an OOP hierarchy
    under a new 'proxy' package. RemoteRequestProxy provides the core
    proxying logic; GenericV1RequestProxy and V2SolrRequestBasedProxy
    handle v1 and v2 requests respectively. Callers can now customize
    per-endpoint behaviour (response handling, param names, request
    construction) via subclassing rather than branching in a single class.
    
    Also adds WrappedSolrRequest, a delegating SolrRequest decorator used
    by V2SolrRequestBasedProxy to strip internal params before proxying.
---
 .../solr/client/api/model/NodeSystemResponse.java  |  16 ++
 .../solr/handler/admin/AdminHandlersProxy.java     | 261 ---------------------
 .../apache/solr/handler/admin/LoggingHandler.java  |   5 +-
 .../apache/solr/handler/admin/MetricsHandler.java  |  43 +++-
 .../solr/handler/admin/SystemInfoHandler.java      |   6 +-
 .../apache/solr/handler/admin/api/GetMetrics.java  |  11 +-
 .../solr/handler/admin/api/GetNodeSystemInfo.java  |  28 ++-
 .../handler/admin/proxy/GenericV1RequestProxy.java |  77 ++++++
 .../handler/admin/proxy/RemoteRequestProxy.java    | 172 ++++++++++++++
 .../admin/proxy/V2SolrRequestBasedProxy.java       |  81 +++++++
 .../solr/handler/admin/proxy/package-info.java     |  19 ++
 ...sProxyTest.java => RemoteRequestProxyTest.java} |   2 +-
 .../admin/proxy/GenericV1RequestProxyTest.java     | 103 ++++++++
 .../admin/proxy/V2SolrRequestBasedProxyTest.java   | 106 +++++++++
 .../solr/client/solrj/WrappedSolrRequest.java      | 193 +++++++++++++++
 .../solr/client/solrj/WrappedSolrRequestTest.java  | 244 +++++++++++++++++++
 16 files changed, 1086 insertions(+), 281 deletions(-)

diff --git 
a/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java 
b/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
index 024f53cdffc..1811c05598d 100644
--- a/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
+++ b/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
@@ -16,6 +16,8 @@
  */
 package org.apache.solr.client.api.model;
 
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import java.util.Date;
 import java.util.List;
@@ -25,6 +27,20 @@ import java.util.Set;
 /** Response from /node/system */
 public class NodeSystemResponse extends SolrJerseyResponse {
 
+  // TODO The typing here is kindof wonky - can I tighten 'Object' here to be 
NodeSystemResponse or
+  // will Jackson choke on that?
+  public Map<String, Object> remoteNodeData;
+
+  @JsonAnyGetter
+  public Map<String, Object> remoteNodeData() {
+    return remoteNodeData;
+  }
+
+  @JsonAnySetter
+  public void setRemoteNodeResponse(String field, Object value) {
+    remoteNodeData.put(field, value);
+  }
+
   @JsonProperty public String host;
   @JsonProperty public String node;
   @JsonProperty public String mode;
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/AdminHandlersProxy.java 
b/solr/core/src/java/org/apache/solr/handler/admin/AdminHandlersProxy.java
deleted file mode 100644
index a91db17d9bb..00000000000
--- a/solr/core/src/java/org/apache/solr/handler/admin/AdminHandlersProxy.java
+++ /dev/null
@@ -1,261 +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;
-
-import java.io.IOException;
-import java.lang.invoke.MethodHandles;
-import java.net.URI;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import org.apache.solr.client.solrj.SolrRequest;
-import org.apache.solr.client.solrj.SolrServerException;
-import org.apache.solr.client.solrj.request.GenericSolrRequest;
-import org.apache.solr.client.solrj.request.GenericV2SolrRequest;
-import org.apache.solr.client.solrj.response.InputStreamResponseParser;
-import org.apache.solr.cloud.ZkController;
-import org.apache.solr.common.SolrException;
-import org.apache.solr.common.params.CommonParams;
-import org.apache.solr.common.params.ModifiableSolrParams;
-import org.apache.solr.common.params.SolrParams;
-import org.apache.solr.common.util.NamedList;
-import org.apache.solr.core.CoreContainer;
-import org.apache.solr.request.SolrQueryRequest;
-import org.apache.solr.response.SolrQueryResponse;
-import org.apache.solr.util.stats.MetricUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Static methods to proxy calls to an Admin (GET) API to other nodes in the 
cluster and return a
- * combined response
- */
-public class AdminHandlersProxy {
-  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
-  private static final String PARAM_NODES = "nodes";
-  private static final String PARAM_NODE = "node";
-  private static final long PROMETHEUS_FETCH_TIMEOUT_SECONDS = 10;
-
-  /**
-   * Proxy this request to a different remote node's V1 API if 'node' or 
'nodes' parameter is
-   * provided. For V2, use {@link AdminHandlersProxy#maybeProxyToNodes(String, 
SolrQueryRequest,
-   * SolrQueryResponse, CoreContainer)}
-   */
-  public static boolean maybeProxyToNodes(
-      SolrQueryRequest req, SolrQueryResponse rsp, CoreContainer container)
-      throws IOException, SolrServerException, InterruptedException {
-    return maybeProxyToNodes("V1", req, rsp, container);
-  }
-
-  /**
-   * Proxy this request to a different remote node's selected API version if 
'node' or 'nodes'
-   * parameter is provided
-   */
-  public static boolean maybeProxyToNodes(
-      String apiVersion, SolrQueryRequest req, SolrQueryResponse rsp, 
CoreContainer container)
-      throws IOException, SolrServerException, InterruptedException {
-
-    String pathStr = req.getPath();
-    ModifiableSolrParams params = new ModifiableSolrParams(req.getParams());
-
-    // Check if response format is Prometheus/OpenMetrics
-    String wt = params.get(CommonParams.WT);
-    boolean isPrometheusFormat =
-        MetricUtils.PROMETHEUS_METRICS_WT.equals(wt)
-            || MetricUtils.OPEN_METRICS_WT.equals(wt)
-            || (wt == null && pathStr.endsWith("/metrics"));
-
-    if (isPrometheusFormat) {
-      // Prometheus format: use singular 'node' parameter for single-node proxy
-      String nodeName = req.getParams().get(PARAM_NODE);
-      if (nodeName == null || nodeName.isEmpty()) {
-        return false; // No node parameter, handle locally
-      }
-
-      params.remove(PARAM_NODE);
-      handlePrometheusSingleNode(apiVersion, nodeName, pathStr, params, 
container, rsp);
-    } else {
-      // Other formats (JSON/XML): use plural 'nodes' parameter for multi-node 
aggregation
-      String nodeNames = req.getParams().get(PARAM_NODES);
-      if (nodeNames == null || nodeNames.isEmpty()) {
-        return false; // No nodes parameter, handle locally
-      }
-
-      params.remove(PARAM_NODES);
-      Set<String> nodes = resolveNodes(nodeNames, container);
-      handleNamedListFormat(apiVersion, nodes, pathStr, params, 
container.getZkController(), rsp);
-    }
-
-    return true;
-  }
-
-  /** Handle non-Prometheus formats using the existing NamedList approach. */
-  private static void handleNamedListFormat(
-      String apiVersion,
-      Set<String> nodes,
-      String pathStr,
-      SolrParams params,
-      ZkController zkController,
-      SolrQueryResponse rsp) {
-
-    Map<String, Future<NamedList<Object>>> responses = new LinkedHashMap<>();
-    for (String node : nodes) {
-      responses.put(node, callRemoteNode(apiVersion, node, pathStr, params, 
zkController));
-    }
-
-    for (Map.Entry<String, Future<NamedList<Object>>> entry : 
responses.entrySet()) {
-      try {
-        NamedList<Object> resp = entry.getValue().get(10, TimeUnit.SECONDS);
-        rsp.add(entry.getKey(), resp);
-      } catch (ExecutionException ee) {
-        log.warn(
-            "Exception when fetching result from node {}", entry.getKey(), 
ee.getCause()); // nowarn
-      } catch (TimeoutException te) {
-        log.warn("Timeout when fetching result from node {}", entry.getKey());
-      } catch (InterruptedException e) {
-        log.warn("Interrupted when fetching result from node {}", 
entry.getKey());
-        Thread.currentThread().interrupt();
-        break; // stop early
-      }
-    }
-    if (log.isDebugEnabled()) {
-      log.debug("Fetched response from {} nodes: {}", responses.size(), 
responses.keySet());
-    }
-  }
-
-  /** Makes a remote request asynchronously. */
-  private static CompletableFuture<NamedList<Object>> callRemoteNode(
-      String apiVersion,
-      String nodeName,
-      String uriPath,
-      SolrParams params,
-      ZkController zkController) {
-
-    // Validate that the node exists in the cluster
-    if 
(!zkController.zkStateReader.getClusterState().getLiveNodes().contains(nodeName))
 {
-      throw new SolrException(
-          SolrException.ErrorCode.BAD_REQUEST,
-          "Requested node " + nodeName + " is not part of cluster");
-    }
-
-    log.debug("Proxying {} request to node {}", uriPath, nodeName);
-    URI baseUri = 
URI.create(zkController.zkStateReader.getBaseUrlForNodeName(nodeName));
-
-    SolrRequest<?> proxyReq = createRequest(apiVersion, uriPath, params);
-
-    // Set response parser based on wt parameter to ensure correct format is 
used
-    String wt = params.get(CommonParams.WT);
-    if (MetricUtils.PROMETHEUS_METRICS_WT.equals(wt) || 
MetricUtils.OPEN_METRICS_WT.equals(wt)) {
-      proxyReq.setResponseParser(new InputStreamResponseParser(wt));
-    }
-    if (wt == null && uriPath.endsWith("/metrics")) {
-      proxyReq.setResponseParser(new 
InputStreamResponseParser(MetricUtils.PROMETHEUS_METRICS_WT));
-    }
-
-    try {
-      return zkController
-          .getCoreContainer()
-          .getDefaultHttpSolrClient()
-          .requestWithBaseUrl(baseUri.toString(), c -> 
c.requestAsync(proxyReq));
-    } catch (SolrServerException | IOException e) {
-      // requestWithBaseUrl declares it throws these but it actually depends 
on the lambda
-      assert false : "requestAsync doesn't throw; it returns a Future";
-      throw new RuntimeException(e);
-    }
-  }
-
-  /**
-   * Resolve node names from the "nodes" parameter into a set of live node 
names.
-   *
-   * @param nodeNames the value of the "nodes" parameter ("all" or 
comma-separated node names)
-   * @param container the CoreContainer
-   * @return set of resolved node names
-   * @throws SolrException if node format is invalid
-   */
-  private static Set<String> resolveNodes(String nodeNames, CoreContainer 
container) {
-    Set<String> liveNodes =
-        
container.getZkController().zkStateReader.getClusterState().getLiveNodes();
-
-    if (nodeNames.equals("all")) {
-      log.debug("All live nodes requested");
-      return liveNodes;
-    }
-
-    Set<String> nodes = new HashSet<>(Arrays.asList(nodeNames.split(",")));
-    for (String nodeName : nodes) {
-      if (!nodeName.matches("^[^/:]+:\\d+_[\\w/]+$")) {
-        throw new SolrException(
-            SolrException.ErrorCode.BAD_REQUEST, "Parameter " + PARAM_NODES + 
" has wrong format");
-      }
-    }
-    log.debug("Nodes requested: {}", nodes);
-    return nodes;
-  }
-
-  /**
-   * Handle Prometheus format by proxying to a single node. *
-   *
-   * @param nodeName the name of the single node to proxy to
-   * @param pathStr the request path
-   * @param params the request parameters (with 'node' parameter already 
removed)
-   * @param container the CoreContainer
-   * @param rsp the response to populate
-   */
-  private static void handlePrometheusSingleNode(
-      String apiVersion,
-      String nodeName,
-      String pathStr,
-      ModifiableSolrParams params,
-      CoreContainer container,
-      SolrQueryResponse rsp)
-      throws IOException, SolrServerException {
-
-    // Keep wt=prometheus for the remote request so MetricsHandler accepts it
-    // The InputStreamResponseParser will return the Prometheus text in a 
"stream" key
-    Future<NamedList<Object>> response =
-        callRemoteNode(apiVersion, nodeName, pathStr, params, 
container.getZkController());
-
-    try {
-      try {
-        NamedList<Object> resp = 
response.get(PROMETHEUS_FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
-        rsp.getValues().addAll(resp);
-      } catch (ExecutionException e) {
-        throw e.getCause();
-      }
-    } catch (IOException | SolrServerException | RuntimeException | Error e) {
-      throw e;
-    } catch (Throwable t) { // unlikely?
-      throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, t);
-    }
-  }
-
-  private static SolrRequest<?> createRequest(
-      String apiVersion, String uriPath, SolrParams params) {
-    if (apiVersion.equalsIgnoreCase("V1")) {
-      return new GenericSolrRequest(SolrRequest.METHOD.GET, uriPath, params);
-    }
-    return new GenericV2SolrRequest(SolrRequest.METHOD.GET, uriPath, params);
-  }
-}
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java 
b/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java
index 7593bb7cbdd..77e65ede715 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java
@@ -31,6 +31,7 @@ import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.core.CoreContainer;
 import org.apache.solr.handler.RequestHandlerBase;
 import org.apache.solr.handler.admin.api.NodeLogging;
+import org.apache.solr.handler.admin.proxy.GenericV1RequestProxy;
 import org.apache.solr.handler.api.V2ApiUtils;
 import org.apache.solr.logging.LogWatcher;
 import org.apache.solr.request.SolrQueryRequest;
@@ -91,9 +92,7 @@ public class LoggingHandler extends RequestHandlerBase {
     }
 
     rsp.setHttpCaching(false);
-    if (cc != null && AdminHandlersProxy.maybeProxyToNodes(req, rsp, cc)) {
-      return; // Request was proxied to other node
-    }
+    new GenericV1RequestProxy(cc, req, rsp).proxyRequest();
   }
 
   private void squashV2Response(SolrQueryResponse rsp, LoggingResponse 
response) {
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/MetricsHandler.java 
b/solr/core/src/java/org/apache/solr/handler/admin/MetricsHandler.java
index eaa5510bb02..784676656ae 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/MetricsHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/MetricsHandler.java
@@ -17,6 +17,8 @@
 
 package org.apache.solr.handler.admin;
 
+import static org.apache.solr.common.params.CommonParams.METRICS_PATH;
+
 import io.prometheus.metrics.model.snapshots.MetricSnapshot;
 import io.prometheus.metrics.model.snapshots.MetricSnapshots;
 import java.util.ArrayList;
@@ -26,12 +28,17 @@ import java.util.Set;
 import java.util.SortedMap;
 import java.util.function.BiConsumer;
 import org.apache.solr.api.JerseyResource;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.response.InputStreamResponseParser;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.CommonParams;
 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.RequestHandlerBase;
 import org.apache.solr.handler.admin.api.GetMetrics;
+import org.apache.solr.handler.admin.proxy.GenericV1RequestProxy;
+import org.apache.solr.handler.admin.proxy.RemoteRequestProxy;
 import org.apache.solr.metrics.SolrMetricManager;
 import org.apache.solr.metrics.otel.FilterablePrometheusMetricReader;
 import org.apache.solr.request.SolrQueryRequest;
@@ -101,9 +108,12 @@ public class MetricsHandler extends RequestHandlerBase 
implements PermissionName
               + format);
     }
 
-    if (cc != null && AdminHandlersProxy.maybeProxyToNodes(req, rsp, cc)) {
+    final var reqProxy = createMetricProxy(cc, req, rsp);
+    if (cc != null && reqProxy.shouldProxy()) {
+      reqProxy.proxyRequest();
       return; // Request was proxied to other node
     }
+
     SolrRequestInfo.setRequestInfo(new SolrRequestInfo(req, rsp));
     try {
       handleRequest(req.getParams(), (k, v) -> rsp.add(k, v));
@@ -143,6 +153,37 @@ public class MetricsHandler extends RequestHandlerBase 
implements PermissionName
     consumer.accept("metrics", mergedSnapshots);
   }
 
+  public static RemoteRequestProxy createMetricProxy(
+      CoreContainer cc, SolrQueryRequest req, SolrQueryResponse rsp) {
+    return new GenericV1RequestProxy(cc, req, rsp) {
+
+      // Metric requests use 'node' to proxy rather than the generally 
accepted "nodes"
+      @Override
+      protected String getDestinationNodeParamName() {
+        return "node";
+      }
+
+      // Metrics requests require a particular ResponseParser
+      @Override
+      protected SolrRequest<?> createGenericRequest(String apiPath, SolrParams 
params) {
+        final var toProxy = super.createGenericRequest(apiPath, params);
+        // Metrics proxy might be called from either v1 or v2, but end up 
proxying to v1 for
+        // simplicity
+        toProxy.setPath(METRICS_PATH);
+        String wt = params.get(CommonParams.WT, 
MetricUtils.PROMETHEUS_METRICS_WT);
+        toProxy.setResponseParser(new InputStreamResponseParser(wt));
+
+        return toProxy;
+      }
+
+      // Metrics requests only proxy to single host, so proxied response is 
added at root level
+      @Override
+      public void processProxiedResponse(String nodeName, NamedList<Object> 
proxiedResponse) {
+        rsp.getValues().addAll(proxiedResponse);
+      }
+    };
+  }
+
   @Override
   public String getDescription() {
     return "A handler to return all the metrics gathered by Solr";
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java 
b/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java
index 20fe09098d2..e11343f15da 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java
@@ -24,6 +24,7 @@ import org.apache.solr.client.api.model.NodeSystemResponse;
 import org.apache.solr.core.CoreContainer;
 import org.apache.solr.handler.RequestHandlerBase;
 import org.apache.solr.handler.admin.api.GetNodeSystemInfo;
+import org.apache.solr.handler.admin.proxy.GenericV1RequestProxy;
 import org.apache.solr.handler.api.V2ApiUtils;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.response.SolrQueryResponse;
@@ -49,8 +50,9 @@ public class SystemInfoHandler extends RequestHandlerBase {
   public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) 
throws Exception {
     rsp.setHttpCaching(false);
 
-    if (AdminHandlersProxy.maybeProxyToNodes(req, rsp, getCoreContainer(req))) 
{
-      return; // Request was proxied to other node
+    final var reqProxy = new GenericV1RequestProxy(getCoreContainer(req), req, 
rsp);
+    if (reqProxy.proxyRequest()) {
+      return;
     }
 
     SystemInfoProvider provider = new SystemInfoProvider(req);
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/GetMetrics.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/GetMetrics.java
index ad45a8ad14b..f4322af2c93 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/GetMetrics.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/GetMetrics.java
@@ -35,7 +35,7 @@ import org.apache.solr.common.params.CommonParams;
 import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.core.CoreContainer;
-import org.apache.solr.handler.admin.AdminHandlersProxy;
+import org.apache.solr.handler.admin.MetricsHandler;
 import org.apache.solr.jersey.PermissionName;
 import org.apache.solr.metrics.SolrMetricManager;
 import org.apache.solr.metrics.otel.FilterablePrometheusMetricReader;
@@ -69,6 +69,8 @@ public class GetMetrics extends AdminAPIBase implements 
MetricsApi {
     this.enabled = coreContainer.getConfig().getMetricsConfig().isEnabled();
   }
 
+  // TODO Rewrite implementing logic to use method parameters, rather than 
wrapping everything in a
+  // SolrParams.
   @Override
   @PermissionName(PermissionNameProvider.Name.METRICS_READ_PERM)
   public StreamingOutput getMetrics(
@@ -136,9 +138,10 @@ public class GetMetrics extends AdminAPIBase implements 
MetricsApi {
 
   private boolean proxyToNodes() {
     try {
-      if (coreContainer != null
-          && AdminHandlersProxy.maybeProxyToNodes(
-              "V2", solrQueryRequest, solrQueryResponse, coreContainer)) {
+      final var reqProxy =
+          MetricsHandler.createMetricProxy(coreContainer, solrQueryRequest, 
solrQueryResponse);
+      if (coreContainer != null && reqProxy.shouldProxy()) {
+        reqProxy.proxyRequest();
         return true; // Request was proxied to other node
       }
     } catch (Exception e) {
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
index 5885489f78e..b865fb09d2e 100644
--- 
a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
+++ 
b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
@@ -21,10 +21,11 @@ import java.lang.invoke.MethodHandles;
 import org.apache.solr.api.JerseyResource;
 import org.apache.solr.client.api.endpoint.NodeSystemInfoApi;
 import org.apache.solr.client.api.model.NodeSystemResponse;
+import org.apache.solr.client.solrj.request.SystemApi;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.core.CoreContainer;
-import org.apache.solr.handler.admin.AdminHandlersProxy;
 import org.apache.solr.handler.admin.SystemInfoProvider;
+import org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy;
 import org.apache.solr.jersey.PermissionName;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.response.SolrQueryResponse;
@@ -50,14 +51,15 @@ public class GetNodeSystemInfo extends JerseyResource 
implements NodeSystemInfoA
   @Override
   @PermissionName(PermissionNameProvider.Name.CONFIG_READ_PERM)
   public NodeSystemResponse getNodeSystemInfo(String nodes) {
+    NodeSystemResponse response = 
instantiateJerseyResponse(NodeSystemResponse.class);
     solrQueryResponse.setHttpCaching(false);
 
-    if (proxyToNodes()) {
-      return null; // Request handled via proxying
+    if (proxyToNodes(response, nodes)) {
+      return response; // Request handled via proxying
     }
 
+    // No proxying done; populate data for this node.
     SystemInfoProvider provider = new SystemInfoProvider(solrQueryRequest);
-    NodeSystemResponse response = 
instantiateJerseyResponse(NodeSystemResponse.class);
     provider.getNodeSystemInfo(response);
     if (log.isTraceEnabled()) {
       log.trace("Node {}, core root: {}", response.node, response.coreRoot);
@@ -65,12 +67,20 @@ public class GetNodeSystemInfo extends JerseyResource 
implements NodeSystemInfoA
     return response;
   }
 
-  private boolean proxyToNodes() {
+  private boolean proxyToNodes(NodeSystemResponse response, String nodes) {
     try {
-      if (coreContainer != null
-          && AdminHandlersProxy.maybeProxyToNodes(
-              "V2", solrQueryRequest, solrQueryResponse, coreContainer)) {
-        return true; // Request was proxied to other node
+      if (coreContainer != null) {
+        final var req = new SystemApi.GetNodeSystemInfo();
+        req.setNodes(nodes);
+        final var reqProxy =
+            new V2SolrRequestBasedProxy<NodeSystemResponse>(coreContainer, 
req) {
+              @Override
+              public void processTypedProxiedResponse(
+                  String nodeName, NodeSystemResponse proxiedResponse) {
+                response.remoteNodeData.put(nodeName, proxiedResponse);
+              }
+            };
+        return reqProxy.proxyRequest();
       }
     } catch (Exception e) {
       throw new SolrException(
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/proxy/GenericV1RequestProxy.java
 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/GenericV1RequestProxy.java
new file mode 100644
index 00000000000..7e54aeb28a3
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/GenericV1RequestProxy.java
@@ -0,0 +1,77 @@
+/*
+ * 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.proxy;
+
+import java.util.Collection;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.request.GenericSolrRequest;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+
+/** Uses a v1 {@link GenericSolrRequest} to proxy the executing (v1) request 
to remote nodes */
+public class GenericV1RequestProxy extends RemoteRequestProxy {
+
+  private final ModifiableSolrParams params;
+  private final SolrQueryRequest req;
+  private final SolrQueryResponse rsp;
+
+  public GenericV1RequestProxy(
+      CoreContainer coreContainer, SolrQueryRequest req, SolrQueryResponse 
rsp) {
+    super(coreContainer);
+    this.req = req;
+    this.params = new ModifiableSolrParams(req.getParams());
+    this.rsp = rsp;
+  }
+
+  @Override
+  public boolean shouldProxy() {
+    String nodeNames = params.get(getDestinationNodeParamName());
+    if (nodeNames == null || nodeNames.isEmpty()) {
+      return false; // No nodes parameter, handle locally
+    }
+    return true;
+  }
+
+  @Override
+  public Collection<String> getDestinationNodes() {
+    return validateNodeNames(params.get(getDestinationNodeParamName()));
+  }
+
+  @Override
+  public SolrRequest<?> prepareProxiedRequest() {
+    params.remove(getDestinationNodeParamName());
+    return createGenericRequest(req.getPath(), params);
+  }
+
+  @Override
+  public void processProxiedResponse(String nodeName, NamedList<Object> 
proxiedResponse) {
+    rsp.add(nodeName, proxiedResponse);
+  }
+
+  /** The name of the query-param that indicates which node(s) should be 
proxied to. */
+  protected String getDestinationNodeParamName() {
+    return PARAM_NODES;
+  }
+
+  protected SolrRequest<?> createGenericRequest(String apiPath, SolrParams 
params) {
+    return new GenericSolrRequest(SolrRequest.METHOD.GET, apiPath, params);
+  }
+}
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
new file mode 100644
index 00000000000..3520e880c14
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
@@ -0,0 +1,172 @@
+/*
+ * 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.proxy;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.CoreContainer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Proxies an API call to remote nodes; exposing hooks to process responses
+ *
+ * @lucene.experimental
+ */
+public abstract class RemoteRequestProxy {
+  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  protected static final String PARAM_NODES = "nodes";
+
+  protected final CoreContainer coreContainer;
+
+  public RemoteRequestProxy(CoreContainer container) {
+    this.coreContainer = container;
+  }
+
+  /** Indicates whether a particular request should be proxied to other nodes 
or not. */
+  public abstract boolean shouldProxy();
+
+  /**
+   * Returns the nodes that the request should be proxied to.
+   *
+   * <p>Only called when {@link #shouldProxy()} returns true. Returned strings 
are in "live nodes"
+   * format (i.e. "someHost:8983_solr")
+   */
+  public abstract Collection<String> getDestinationNodes();
+
+  /** Creates a {@link SolrRequest} instance representing the request being 
proxied. */
+  public abstract SolrRequest<?> prepareProxiedRequest();
+
+  /**
+   * Process the response from request proxying to a particular node.
+   *
+   * @param nodeName the node that the proxied response is being received 
from, in "live node"
+   *     format (i.e. "someHost:8983_solr")
+   * @param proxiedResponse the proxied response, as a NamedList
+   */
+  public abstract void processProxiedResponse(String nodeName, 
NamedList<Object> proxiedResponse);
+
+  public boolean proxyRequest() {
+    if (!shouldProxy()) {
+      return false;
+    }
+
+    final var nodesToProxyTo = getDestinationNodes();
+    final var solrRequest = prepareProxiedRequest();
+    final var responseFutures = doProxyToNodes(nodesToProxyTo, solrRequest);
+    bulkProcessResponses(responseFutures);
+    return true;
+  }
+
+  private void bulkProcessResponses(Map<String, Future<NamedList<Object>>> 
responseFutures) {
+    for (Map.Entry<String, Future<NamedList<Object>>> entry : 
responseFutures.entrySet()) {
+      try {
+        NamedList<Object> resp = entry.getValue().get(10, TimeUnit.SECONDS);
+        processProxiedResponse(entry.getKey(), resp);
+      } catch (ExecutionException | InterruptedException ee) {
+        log.warn(
+            "Exception when fetching result from node {}", entry.getKey(), 
ee.getCause()); // nowarn
+      } catch (TimeoutException e) {
+        log.warn("Timeout exceeded waiting for response from proxied node {}", 
entry.getKey());
+      }
+    }
+    if (log.isDebugEnabled()) {
+      log.debug(
+          "Fetched response from {} nodes: {}", responseFutures.size(), 
responseFutures.keySet());
+    }
+  }
+
+  private Map<String, Future<NamedList<Object>>> doProxyToNodes(
+      Collection<String> nodesToProxyTo, SolrRequest<?> solrRequest) {
+    Map<String, Future<NamedList<Object>>> responses = new LinkedHashMap<>();
+    for (String node : nodesToProxyTo) {
+      responses.put(node, callRemoteNode(node, solrRequest));
+    }
+    return responses;
+  }
+
+  /** Makes a remote request asynchronously. */
+  private CompletableFuture<NamedList<Object>> callRemoteNode(
+      String nodeName, SolrRequest<?> solrRequest) {
+
+    final var zkController = coreContainer.getZkController();
+    // Validate that the node exists in the cluster
+    if 
(!zkController.zkStateReader.getClusterState().getLiveNodes().contains(nodeName))
 {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Requested node " + nodeName + " is not part of cluster");
+    }
+
+    log.debug("Proxying {} request to node {}", solrRequest, nodeName);
+    URI baseUri = 
URI.create(zkController.zkStateReader.getBaseUrlForNodeName(nodeName));
+
+    try {
+      return zkController
+          .getCoreContainer()
+          .getDefaultHttpSolrClient()
+          .requestWithBaseUrl(baseUri.toString(), c -> 
c.requestAsync(solrRequest));
+    } catch (SolrServerException | IOException e) {
+      // requestWithBaseUrl declares it throws these but it actually depends 
on the lambda
+      assert false : "requestAsync doesn't throw; it returns a Future";
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Resolve node names from the "nodes" parameter into a set of live node 
names.
+   *
+   * @param nodeNames the value of the "nodes" parameter ("all" or 
comma-separated node names)
+   * @return set of resolved node names
+   * @throws SolrException if node format is invalid
+   */
+  protected Set<String> validateNodeNames(String nodeNames) {
+    Set<String> liveNodes =
+        
coreContainer.getZkController().zkStateReader.getClusterState().getLiveNodes();
+
+    if (nodeNames.equals("all")) {
+      log.debug("All live nodes requested");
+      return liveNodes;
+    }
+
+    Set<String> nodes = new HashSet<>(Arrays.asList(nodeNames.split(",")));
+    for (String nodeName : nodes) {
+      if (!nodeName.matches("^[^/:]+:\\d+_[\\w/]+$")) {
+        throw new SolrException(
+            SolrException.ErrorCode.BAD_REQUEST, "Parameter " + PARAM_NODES + 
" has wrong format");
+      }
+    }
+    log.debug("Nodes requested: {}", nodes);
+    return nodes;
+  }
+}
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxy.java
 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxy.java
new file mode 100644
index 00000000000..d09757e9b52
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxy.java
@@ -0,0 +1,81 @@
+/*
+ * 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.proxy;
+
+import java.util.Collection;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.WrappedSolrRequest;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.CoreContainer;
+
+/**
+ * Uses a v2 {@link SolrRequest} instance to proxy requests to other nodes.
+ *
+ * <p>While this implementation is intended for v2 requests, callers may use 
it for v1 requests as
+ * well by overriding {@link #processProxiedResponse(String, NamedList)} to do 
something more
+ * appropriate with the response.
+ */
+public abstract class V2SolrRequestBasedProxy<T> extends RemoteRequestProxy {
+
+  private SolrRequest<T> solrRequest;
+
+  public V2SolrRequestBasedProxy(CoreContainer coreContainer, SolrRequest<T> 
solrRequest) {
+    super(coreContainer);
+    this.solrRequest = solrRequest;
+  }
+
+  @Override
+  public boolean shouldProxy() {
+    String nodeNames = solrRequest.getParams().get(PARAM_NODES);
+    if (nodeNames == null || nodeNames.isEmpty()) {
+      return false; // No nodes parameter, handle locally
+    }
+    return true;
+  }
+
+  @Override
+  public Collection<String> getDestinationNodes() {
+    return validateNodeNames(solrRequest.getParams().get(PARAM_NODES));
+  }
+
+  @Override
+  public SolrRequest<T> prepareProxiedRequest() {
+    return new WrappedSolrRequest<>(solrRequest) {
+      @Override
+      public SolrParams getParams() {
+        final var originalParams = this.wrapped.getParams();
+        final var mutable =
+            (originalParams instanceof ModifiableSolrParams myMut)
+                ? myMut
+                : new ModifiableSolrParams(originalParams);
+        mutable.remove(PARAM_NODES);
+        return mutable;
+      }
+    };
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public void processProxiedResponse(String nodeName, NamedList<Object> 
proxiedResponse) {
+    final var typedResponse = (T) proxiedResponse.get("response");
+    processTypedProxiedResponse(nodeName, typedResponse);
+  }
+
+  protected abstract void processTypedProxiedResponse(String nodeName, T 
proxiedResponse);
+}
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/proxy/package-info.java 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/package-info.java
new file mode 100644
index 00000000000..e8f03767637
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/handler/admin/proxy/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+
+/** Utilities for proxying requests to other nodes within the Solr cluster */
+package org.apache.solr.handler.admin.proxy;
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/AdminHandlersProxyTest.java 
b/solr/core/src/test/org/apache/solr/handler/admin/RemoteRequestProxyTest.java
similarity index 98%
rename from 
solr/core/src/test/org/apache/solr/handler/admin/AdminHandlersProxyTest.java
rename to 
solr/core/src/test/org/apache/solr/handler/admin/RemoteRequestProxyTest.java
index 8e7b383db7f..be07381f78e 100644
--- 
a/solr/core/src/test/org/apache/solr/handler/admin/AdminHandlersProxyTest.java
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/RemoteRequestProxyTest.java
@@ -32,7 +32,7 @@ import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
-public class AdminHandlersProxyTest extends SolrCloudTestCase {
+public class RemoteRequestProxyTest extends SolrCloudTestCase {
   private CloudSolrClient solrClient;
 
   @BeforeClass
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/proxy/GenericV1RequestProxyTest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/proxy/GenericV1RequestProxyTest.java
new file mode 100644
index 00000000000..b146533aa2b
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/proxy/GenericV1RequestProxyTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.proxy;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Unit tests for {@link GenericV1RequestProxy} */
+public class GenericV1RequestProxyTest extends SolrTestCaseJ4 {
+
+  @BeforeClass
+  public static void ensureWorkingMockito() {
+    assumeWorkingMockito();
+  }
+
+  private ModifiableSolrParams solrParams;
+  private SolrQueryRequest mockRequest;
+
+  @Before
+  public void setUpMocks() {
+    solrParams = new ModifiableSolrParams();
+    mockRequest = mock(SolrQueryRequest.class);
+
+    when(mockRequest.getParams()).thenReturn(solrParams);
+  }
+
+  @Test
+  public void shouldProxyReflectsPresenceOfNodesParam() {
+    var proxy = new GenericV1RequestProxy(null, mockRequest, new 
SolrQueryResponse());
+    assertFalse(
+        "Expected 'shouldProxy' to return false when 'nodes' param is absent", 
proxy.shouldProxy());
+
+    solrParams.add("nodes", "localhost:7574_solr");
+    proxy = new GenericV1RequestProxy(null, mockRequest, new 
SolrQueryResponse());
+    assertTrue(
+        "Expected 'shouldProxy' to return true when 'nodes' param is present", 
proxy.shouldProxy());
+  }
+
+  // ---- getDestinationNodes() tests ----
+  //
+  // validateNodeNames() requires a live ZkController, so these tests use an 
anonymous subclass
+  // that stubs it out, keeping the focus on getDestinationNodes()'s own 
param-reading logic.
+
+  @Test
+  public void testDestinationNodesExtractsValueFromNodes() {
+    solrParams.add("nodes", "somehost:8983_solr");
+
+    // Stub out live-node validation for tests.
+    GenericV1RequestProxy proxy =
+        new GenericV1RequestProxy(null, mockRequest, new SolrQueryResponse()) {
+          @Override
+          protected Set<String> validateNodeNames(String nodeNames) {
+            return new HashSet<>(Arrays.asList(nodeNames.split(",")));
+          }
+        };
+
+    Collection<String> nodes = proxy.getDestinationNodes();
+    assertEquals(Set.of("somehost:8983_solr"), new HashSet<>(nodes));
+  }
+
+  @Test
+  public void testPreparedRequestMirrorsSolrQueryRequestVals() {
+    solrParams.set("nodes", "somehost:8983_solr");
+    solrParams.set("wt", "json");
+    when(mockRequest.getPath()).thenReturn("/admin/info/system");
+
+    GenericV1RequestProxy proxy =
+        new GenericV1RequestProxy(null, mockRequest, new SolrQueryResponse());
+    SolrRequest<?> prepared = proxy.prepareProxiedRequest();
+
+    assertNull(
+        "'nodes' param should be stripped from proxied request", 
prepared.getParams().get("nodes"));
+    assertEquals("json", prepared.getParams().get("wt"));
+    assertEquals("/admin/info/system", prepared.getPath());
+  }
+}
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxyTest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxyTest.java
new file mode 100644
index 00000000000..b0deb0ccdf7
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxyTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.proxy;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Unit tests for {@link V2SolrRequestBasedProxy} */
+public class V2SolrRequestBasedProxyTest extends SolrTestCaseJ4 {
+
+  @BeforeClass
+  public static void ensureWorkingMockito() {
+    assumeWorkingMockito();
+  }
+
+  private ModifiableSolrParams solrParams;
+  private SolrRequest<Object> mockSolrRequest;
+
+  @Before
+  @SuppressWarnings("unchecked")
+  public void setUpMocks() {
+    solrParams = new ModifiableSolrParams();
+    mockSolrRequest = mock(SolrRequest.class);
+    when(mockSolrRequest.getParams()).thenReturn(solrParams);
+    // WrappedSolrRequest's constructor calls these, so stub them to avoid NPEs
+    when(mockSolrRequest.getMethod()).thenReturn(SolrRequest.METHOD.GET);
+    when(mockSolrRequest.getPath()).thenReturn("/api/node/properties");
+    
when(mockSolrRequest.getRequestType()).thenReturn(SolrRequest.SolrRequestType.ADMIN);
+  }
+
+  /** Minimal concrete subclass for testing. */
+  private class TestProxy extends V2SolrRequestBasedProxy<Object> {
+    TestProxy() {
+      super(null, mockSolrRequest);
+    }
+
+    @Override
+    protected void processTypedProxiedResponse(String nodeName, Object 
proxiedResponse) {}
+
+    // Override the live-node validation that usually happens here.
+    @Override
+    protected Set<String> validateNodeNames(String nodeNames) {
+      return new HashSet<>(Arrays.asList(nodeNames.split(",")));
+    }
+  }
+
+  @Test
+  public void shouldProxyReflectsPresenceOfNodesParam() {
+    var proxy = new TestProxy();
+    assertFalse(
+        "Expected 'shouldProxy' to return false when 'nodes' param is absent", 
proxy.shouldProxy());
+
+    solrParams.add("nodes", "localhost:7574_solr");
+    assertTrue(
+        "Expected 'shouldProxy' to return true when 'nodes' param is present", 
proxy.shouldProxy());
+  }
+
+  @Test
+  public void testDestinationNodesExtractsValueFromNodes() {
+    solrParams.add("nodes", "somehost:8983_solr");
+
+    // Stub out live-node validation for tests.
+    final var proxy = new TestProxy();
+
+    Collection<String> nodes = proxy.getDestinationNodes();
+    assertEquals(Set.of("somehost:8983_solr"), new HashSet<>(nodes));
+  }
+
+  @Test
+  public void testPreparedRequestMirrorsSolrRequestVals() {
+    solrParams.set("nodes", "somehost:8983_solr");
+    solrParams.set("wt", "json");
+
+    SolrRequest<Object> prepared = new TestProxy().prepareProxiedRequest();
+
+    assertNull(
+        "'nodes' param should be stripped from proxied request", 
prepared.getParams().get("nodes"));
+    assertEquals("json", prepared.getParams().get("wt"));
+    assertEquals("/api/node/properties", prepared.getPath());
+  }
+}
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/WrappedSolrRequest.java 
b/solr/solrj/src/java/org/apache/solr/client/solrj/WrappedSolrRequest.java
new file mode 100644
index 00000000000..4f2ab125122
--- /dev/null
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/WrappedSolrRequest.java
@@ -0,0 +1,193 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.solr.client.solrj.request.RequestWriter;
+import org.apache.solr.client.solrj.response.ResponseParser;
+import org.apache.solr.client.solrj.response.StreamingResponseCallback;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.ContentStream;
+import org.apache.solr.common.util.NamedList;
+
+/** A {@link SolrRequest} that wrappeds all method calls to a wrapped 
instance. */
+public class WrappedSolrRequest<T> extends SolrRequest<T> {
+
+  protected final SolrRequest<T> wrapped;
+
+  public WrappedSolrRequest(SolrRequest<T> wrapped) {
+    super(wrapped.getMethod(), wrapped.getPath(), wrapped.getRequestType());
+    this.wrapped = wrapped;
+  }
+
+  // -- Abstract methods --
+
+  @Override
+  public SolrParams getParams() {
+    return wrapped.getParams();
+  }
+
+  @Override
+  protected T createResponse(NamedList<Object> namedList) {
+    return wrapped.createResponse(namedList);
+  }
+
+  // -- Overrides for all non-final methods with private state in SolrRequest 
--
+
+  @Override
+  public METHOD getMethod() {
+    return wrapped.getMethod();
+  }
+
+  @Override
+  public void setMethod(METHOD method) {
+    wrapped.setMethod(method);
+  }
+
+  @Override
+  public String getPath() {
+    return wrapped.getPath();
+  }
+
+  @Override
+  public void setPath(String path) {
+    wrapped.setPath(path);
+  }
+
+  @Override
+  public ResponseParser getResponseParser() {
+    return wrapped.getResponseParser();
+  }
+
+  @Override
+  public void setResponseParser(ResponseParser responseParser) {
+    wrapped.setResponseParser(responseParser);
+  }
+
+  @Override
+  public StreamingResponseCallback getStreamingResponseCallback() {
+    return wrapped.getStreamingResponseCallback();
+  }
+
+  @Override
+  public void setStreamingResponseCallback(StreamingResponseCallback callback) 
{
+    wrapped.setStreamingResponseCallback(callback);
+  }
+
+  @Override
+  public Set<String> getQueryParams() {
+    return wrapped.getQueryParams();
+  }
+
+  @Override
+  public void setQueryParams(Set<String> queryParams) {
+    wrapped.setQueryParams(queryParams);
+  }
+
+  @Override
+  public SolrRequestType getRequestType() {
+    return wrapped.getRequestType();
+  }
+
+  @Override
+  public void setRequestType(SolrRequestType requestType) {
+    wrapped.setRequestType(requestType);
+  }
+
+  @Override
+  public List<String> getPreferredNodes() {
+    return wrapped.getPreferredNodes();
+  }
+
+  @Override
+  public SolrRequest<T> setPreferredNodes(List<String> nodes) {
+    wrapped.setPreferredNodes(nodes);
+    return this;
+  }
+
+  @Override
+  public Principal getUserPrincipal() {
+    return wrapped.getUserPrincipal();
+  }
+
+  @Override
+  public void setUserPrincipal(Principal userPrincipal) {
+    wrapped.setUserPrincipal(userPrincipal);
+  }
+
+  @Override
+  public SolrRequest<T> setBasicAuthCredentials(String user, String password) {
+    wrapped.setBasicAuthCredentials(user, password);
+    return this;
+  }
+
+  @Override
+  public String getBasicAuthUser() {
+    return wrapped.getBasicAuthUser();
+  }
+
+  @Override
+  public String getBasicAuthPassword() {
+    return wrapped.getBasicAuthPassword();
+  }
+
+  @Override
+  public boolean requiresCollection() {
+    return wrapped.requiresCollection();
+  }
+
+  @Override
+  public ApiVersion getApiVersion() {
+    return wrapped.getApiVersion();
+  }
+
+  @Override
+  @Deprecated
+  public Collection<ContentStream> getContentStreams() throws IOException {
+    return wrapped.getContentStreams();
+  }
+
+  @Override
+  public RequestWriter.ContentWriter getContentWriter(String expectedType) {
+    return wrapped.getContentWriter(expectedType);
+  }
+
+  @Override
+  public String getCollection() {
+    return wrapped.getCollection();
+  }
+
+  @Override
+  public void addHeader(String key, String value) {
+    wrapped.addHeader(key, value);
+  }
+
+  @Override
+  public void addHeaders(Map<String, String> headers) {
+    wrapped.addHeaders(headers);
+  }
+
+  @Override
+  public Map<String, String> getHeaders() {
+    return wrapped.getHeaders();
+  }
+}
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/WrappedSolrRequestTest.java 
b/solr/solrj/src/test/org/apache/solr/client/solrj/WrappedSolrRequestTest.java
new file mode 100644
index 00000000000..6f8bfe58a64
--- /dev/null
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/WrappedSolrRequestTest.java
@@ -0,0 +1,244 @@
+/*
+ * 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;
+
+import java.security.Principal;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.request.GenericSolrRequest;
+import org.apache.solr.client.solrj.request.RequestWriter;
+import org.apache.solr.client.solrj.response.SimpleSolrResponse;
+import org.apache.solr.client.solrj.response.StreamingResponseCallback;
+import org.apache.solr.client.solrj.response.XMLResponseParser;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link WrappedSolrRequest}; focused on ensuring that the 
"wrapped" and "wrapper"
+ * SolrRequest instances line up on retvals, etc.
+ *
+ * <p>Getters are tested by modifying the "wrapped" value and ensuring the 
"wrapper" reflects the
+ * change. Setters are tested by modifying the "wrapper" value and ensuring 
the change propagates to
+ * the "wrapped" instance.
+ */
+public class WrappedSolrRequestTest extends SolrTestCase {
+
+  private GenericSolrRequest inner;
+  private WrappedSolrRequest<SimpleSolrResponse> wrapper;
+
+  @Before
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    inner = new GenericSolrRequest(SolrRequest.METHOD.GET, "/test");
+    wrapper = new WrappedSolrRequest<>(inner);
+  }
+
+  @Test
+  public void testGetMethod() {
+    inner.setMethod(SolrRequest.METHOD.POST);
+    assertEquals(inner.getMethod(), wrapper.getMethod());
+  }
+
+  @Test
+  public void testSetMethod() {
+    wrapper.setMethod(SolrRequest.METHOD.DELETE);
+    assertEquals(inner.getMethod(), wrapper.getMethod());
+  }
+
+  @Test
+  public void testGetPath() {
+    inner.setPath("/updated");
+    assertEquals(inner.getPath(), wrapper.getPath());
+  }
+
+  @Test
+  public void testSetPath() {
+    wrapper.setPath("/updated");
+    assertEquals(inner.getPath(), wrapper.getPath());
+  }
+
+  @Test
+  public void testGetResponseParser() {
+    inner.setResponseParser(new XMLResponseParser());
+    assertSame(inner.getResponseParser(), wrapper.getResponseParser());
+  }
+
+  @Test
+  public void testSetResponseParser() {
+    XMLResponseParser rp = new XMLResponseParser();
+    wrapper.setResponseParser(rp);
+    assertSame(inner.getResponseParser(), wrapper.getResponseParser());
+  }
+
+  @Test
+  public void testGetStreamingResponseCallback() {
+    StreamingResponseCallback cb = noopCallback();
+    inner.setStreamingResponseCallback(cb);
+    assertSame(inner.getStreamingResponseCallback(), 
wrapper.getStreamingResponseCallback());
+  }
+
+  @Test
+  public void testSetStreamingResponseCallback() {
+    wrapper.setStreamingResponseCallback(noopCallback());
+    assertSame(inner.getStreamingResponseCallback(), 
wrapper.getStreamingResponseCallback());
+  }
+
+  @Test
+  public void testGetQueryParams() {
+    inner.setQueryParams(Set.of("q", "rows"));
+    assertEquals(inner.getQueryParams(), wrapper.getQueryParams());
+  }
+
+  @Test
+  public void testSetQueryParams() {
+    wrapper.setQueryParams(Set.of("q", "rows"));
+    assertEquals(inner.getQueryParams(), wrapper.getQueryParams());
+  }
+
+  @Test
+  public void testGetRequestType() {
+    inner.setRequestType(SolrRequest.SolrRequestType.ADMIN);
+    assertEquals(inner.getRequestType(), wrapper.getRequestType());
+  }
+
+  @Test
+  public void testSetRequestType() {
+    wrapper.setRequestType(SolrRequest.SolrRequestType.QUERY);
+    assertEquals(inner.getRequestType(), wrapper.getRequestType());
+  }
+
+  @Test
+  public void testGetPreferredNodes() {
+    inner.setPreferredNodes(List.of("node1:8983_solr", "node2:8983_solr"));
+    assertEquals(inner.getPreferredNodes(), wrapper.getPreferredNodes());
+  }
+
+  @Test
+  public void testSetPreferredNodes() {
+    wrapper.setPreferredNodes(List.of("node1:8983_solr", "node2:8983_solr"));
+    assertEquals(inner.getPreferredNodes(), wrapper.getPreferredNodes());
+  }
+
+  @Test
+  public void testGetUserPrincipal() {
+    Principal principal = () -> "test-user";
+    inner.setUserPrincipal(principal);
+    assertEquals(inner.getUserPrincipal(), wrapper.getUserPrincipal());
+  }
+
+  @Test
+  public void testSetUserPrincipal() {
+    wrapper.setUserPrincipal(() -> "test-user");
+    assertEquals(inner.getUserPrincipal(), wrapper.getUserPrincipal());
+  }
+
+  @Test
+  public void testGetBasicAuthUser() {
+    inner.setBasicAuthCredentials("alice", "secret");
+    assertEquals(inner.getBasicAuthUser(), wrapper.getBasicAuthUser());
+  }
+
+  @Test
+  public void testGetBasicAuthPassword() {
+    inner.setBasicAuthCredentials("alice", "secret");
+    assertEquals(inner.getBasicAuthPassword(), wrapper.getBasicAuthPassword());
+  }
+
+  @Test
+  public void testSetBasicAuthCredentials() {
+    wrapper.setBasicAuthCredentials("alice", "secret");
+    assertEquals(inner.getBasicAuthUser(), wrapper.getBasicAuthUser());
+    assertEquals(inner.getBasicAuthPassword(), wrapper.getBasicAuthPassword());
+  }
+
+  @Test
+  public void testRequiresCollection() {
+    inner.setRequiresCollection(true);
+    assertEquals(inner.requiresCollection(), wrapper.requiresCollection());
+  }
+
+  @Test
+  public void testGetApiVersion() {
+    assertEquals(inner.getApiVersion(), wrapper.getApiVersion());
+  }
+
+  @Test
+  @SuppressWarnings("UndefinedEquals") // Reference-check equality here is 
fine.
+  public void testGetContentStreams() throws Exception {
+    assertEquals(inner.getContentStreams(), wrapper.getContentStreams());
+  }
+
+  @Test
+  public void testGetContentWriter() {
+    RequestWriter.ContentWriter cw =
+        inner.withContent(new byte[] {1, 2, 3}, 
"application/octet-stream").contentWriter;
+    assertEquals(
+        inner.getContentWriter("application/octet-stream"),
+        wrapper.getContentWriter("application/octet-stream"));
+    assertSame(cw, wrapper.getContentWriter("application/octet-stream"));
+  }
+
+  @Test
+  public void testGetCollection() {
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    params.set("collection", "myCollection");
+    GenericSolrRequest innerWithCollection =
+        new GenericSolrRequest(SolrRequest.METHOD.GET, "/test", params);
+    final var wrapperWithCollection = new 
WrappedSolrRequest<>(innerWithCollection);
+    assertEquals(innerWithCollection.getCollection(), 
wrapperWithCollection.getCollection());
+  }
+
+  @Test
+  public void testGetHeaders() {
+    inner.addHeader("X-Custom", "value");
+    assertEquals(inner.getHeaders(), wrapper.getHeaders());
+  }
+
+  @Test
+  public void testAddHeader() {
+    wrapper.addHeader("X-Custom", "value");
+    assertEquals(inner.getHeaders(), wrapper.getHeaders());
+  }
+
+  @Test
+  public void testAddHeaders() {
+    wrapper.addHeaders(Map.of("X-Foo", "foo", "X-Bar", "bar"));
+    assertEquals(inner.getHeaders(), wrapper.getHeaders());
+  }
+
+  @Test
+  public void testGetParams() {
+    SolrParams params = inner.getParams();
+    assertSame(params, wrapper.getParams());
+  }
+
+  private static StreamingResponseCallback noopCallback() {
+    return new StreamingResponseCallback() {
+      @Override
+      public void streamSolrDocument(SolrDocument doc) {}
+
+      @Override
+      public void streamDocListInfo(long numFound, long start, Float maxScore) 
{}
+    };
+  }
+}

Reply via email to