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)
{}
+ };
+ }
+}