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

dsmiley 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 6de34ce6cd6 SOLR-17316: New SolrJ CanonicalJsonResponseParser for use 
with QueryResponse and others (#4640)
6de34ce6cd6 is described below

commit 6de34ce6cd60c4d92d937ce8c9f6732a814bfe64
Author: Serhiy Bzhezytskyy <[email protected]>
AuthorDate: Tue Sep 15 22:18:18 2026 +0300

    SOLR-17316: New SolrJ CanonicalJsonResponseParser for use with 
QueryResponse and others (#4640)
    
    SolrJ's QueryResponse and other response objects can now read a JSON 
response via the new CanonicalJsonResponseParser.
    
    Co-authored-by: David Smiley <[email protected]>
---
 .../unreleased/SOLR-17316-response-parsers.yml     |  11 +
 .../core/src/java/org/apache/solr/cli/ApiTool.java |  19 +-
 .../client/solrj/embedded/EmbeddedSolrServer.java  |   1 +
 .../apache/solr/packagemanager/PackageUtils.java   |   8 +-
 .../TestEmbeddedSolrServerResponseParser.java      | 118 +++++++
 .../solr/client/solrj/impl/HttpSolrClient.java     |  14 +-
 .../solrj/response/AnalysisResponseBase.java       |   6 +-
 .../solr/client/solrj/response/LukeResponse.java   |   8 +-
 .../solr/client/solrj/response/QueryResponse.java  |  32 +-
 .../solrj/response/ResponseCanonicalizer.java      | 148 +++++++++
 .../solr/client/solrj/response/ResponseParser.java |  15 +
 .../client/solrj/response/SolrResponseBase.java    |   6 +-
 .../client/solrj/response/SpellCheckResponse.java  |  10 +-
 .../response/json/CanonicalJsonResponseParser.java |  56 ++++
 .../solrj/response/schema/SchemaResponse.java      |   3 +-
 .../solr/client/solrj/SolrExampleJsonTest.java     |  32 ++
 .../apache/solr/client/solrj/SolrExampleTests.java |  59 ++--
 .../response/AdminResponseNumericTypeTest.java     | 105 ++++++
 .../QueryResponseJsonParserIntegrationTest.java    |  96 ++++++
 .../response/QueryResponseSectionParityTest.java   | 177 +++++++++++
 .../solrj/response/ResponseCanonicalizerTest.java  | 351 +++++++++++++++++++++
 .../ResponseParserCanonicalResponseTest.java       |  81 +++++
 .../solrj/response/SolrResponseBaseTest.java       |  71 +++++
 .../solrj/response/TestSuggesterResponse.java      |  11 +-
 24 files changed, 1360 insertions(+), 78 deletions(-)

diff --git a/changelog/unreleased/SOLR-17316-response-parsers.yml 
b/changelog/unreleased/SOLR-17316-response-parsers.yml
new file mode 100644
index 00000000000..ea624cfd74b
--- /dev/null
+++ b/changelog/unreleased/SOLR-17316-response-parsers.yml
@@ -0,0 +1,11 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+
+title: >
+  SolrJ's QueryResponse and other response objects can now read a JSON 
response, via the new
+  `CanonicalJsonResponseParser`..
+type: added
+authors:
+  - name: Serhiy Bzhezytskyy
+links:
+  - name: SOLR-17316
+    url: https://issues.apache.org/jira/browse/SOLR-17316
diff --git a/solr/core/src/java/org/apache/solr/cli/ApiTool.java 
b/solr/core/src/java/org/apache/solr/cli/ApiTool.java
index b04d143acd6..a86bf1f545d 100644
--- a/solr/core/src/java/org/apache/solr/cli/ApiTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ApiTool.java
@@ -23,11 +23,8 @@ import org.apache.commons.cli.Option;
 import org.apache.commons.cli.Options;
 import org.apache.solr.client.solrj.SolrRequest;
 import org.apache.solr.client.solrj.request.GenericSolrRequest;
-import org.apache.solr.client.solrj.response.json.JsonMapResponseParser;
+import org.apache.solr.client.solrj.response.InputStreamResponseParser;
 import org.apache.solr.common.params.ModifiableSolrParams;
-import org.apache.solr.common.util.NamedList;
-import org.noggit.CharArr;
-import org.noggit.JSONWriter;
 
 /**
  * Supports api command in the bin/solr script.
@@ -95,16 +92,10 @@ public class ApiTool extends ToolBase {
               path.substring(path.indexOf("/", path.indexOf("/") + 1)),
               getSolrParamsFromUri(uri) // .add("indent", "true")
               );
-      // Using the "smart" solr parsers won't work, because they decode into 
Solr objects.
-      // When trying to re-write into JSON, the JSONWriter doesn't have the 
right info to print it
-      // correctly.
-      // All we want to do is pass the JSON response to the user, so do that.
-      req.setResponseParser(new JsonMapResponseParser());
-      NamedList<Object> response = solrClient.request(req);
-      // pretty-print the response to stdout
-      CharArr arr = new CharArr();
-      new JSONWriter(arr, 2).write(response.asMap(10));
-      return arr.toString();
+      // Pass the server's JSON to the user as it came; parsing and 
re-serialising it here only
+      // risks changing it.
+      req.setResponseParser(new InputStreamResponseParser("json"));
+      return 
InputStreamResponseParser.consumeResponseToString(solrClient.request(req));
     }
   }
 
diff --git 
a/solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java
 
b/solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java
index 0d34187e938..222b01d7957 100644
--- 
a/solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java
+++ 
b/solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java
@@ -244,6 +244,7 @@ public class EmbeddedSolrServer extends SolrClient {
       responseParser = new JavaBinResponseParser();
     }
     var addParams = SolrParams.of(CommonParams.WT, 
responseParser.getWriterType());
+    addParams = SolrParams.wrapDefaults(addParams, 
responseParser.getAdditionalRequestParams());
     return SolrParams.wrapDefaults(addParams, params);
   }
 
diff --git 
a/solr/core/src/java/org/apache/solr/packagemanager/PackageUtils.java 
b/solr/core/src/java/org/apache/solr/packagemanager/PackageUtils.java
index 5a8a0125e61..804d636b4d5 100644
--- a/solr/core/src/java/org/apache/solr/packagemanager/PackageUtils.java
+++ b/solr/core/src/java/org/apache/solr/packagemanager/PackageUtils.java
@@ -41,12 +41,11 @@ import org.apache.solr.client.solrj.SolrRequest;
 import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.request.FileStoreApi;
 import org.apache.solr.client.solrj.request.GenericSolrRequest;
-import org.apache.solr.client.solrj.response.json.JsonMapResponseParser;
+import org.apache.solr.client.solrj.response.InputStreamResponseParser;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrException.ErrorCode;
 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.common.util.Utils;
 import org.apache.solr.filestore.ClusterFileStore;
 import org.apache.solr.filestore.DistribFileStore;
@@ -168,9 +167,8 @@ public class PackageUtils {
       GenericSolrRequest request =
           new GenericSolrRequest(SolrRequest.METHOD.GET, path, params)
               .setRequiresCollection(isCollectionApi);
-      request.setResponseParser(new JsonMapResponseParser());
-      NamedList<Object> response = client.request(request);
-      return response.jsonStr();
+      request.setResponseParser(new InputStreamResponseParser("json"));
+      return 
InputStreamResponseParser.consumeResponseToString(client.request(request));
     } catch (IOException | SolrServerException e) {
       throw new RuntimeException(e);
     }
diff --git 
a/solr/core/src/test/org/apache/solr/client/solrj/embedded/TestEmbeddedSolrServerResponseParser.java
 
b/solr/core/src/test/org/apache/solr/client/solrj/embedded/TestEmbeddedSolrServerResponseParser.java
new file mode 100644
index 00000000000..f1527152916
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/client/solrj/embedded/TestEmbeddedSolrServerResponseParser.java
@@ -0,0 +1,118 @@
+/*
+ * 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.embedded;
+
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.request.QueryRequest;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.response.QueryResponse;
+import org.apache.solr.client.solrj.response.json.CanonicalJsonResponseParser;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.util.EmbeddedSolrServerTestRule;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/**
+ * EmbeddedSolrServer reads the response with the configured parser just as 
the HTTP clients do, so
+ * a non-binary parser has to work here too.
+ */
+public class TestEmbeddedSolrServerResponseParser extends SolrTestCase {
+
+  @ClassRule
+  public static final EmbeddedSolrServerTestRule solrTestRule = new 
EmbeddedSolrServerTestRule();
+
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    solrTestRule.startSolr(SolrTestCaseJ4.TEST_HOME());
+    SolrTestCaseJ4.newRandomConfig();
+    solrTestRule
+        .newCollection()
+        .withConfigSet(SolrTestCaseJ4.TEST_COLL1_CONF())
+        .withSchemaFile("schema-nest.xml")
+        .create();
+
+    SolrInputDocument doc = new SolrInputDocument();
+    doc.addField("id", "1");
+    doc.addField("name_s", "embedded json");
+    SolrClient client = solrTestRule.getSolrClient();
+    client.add(doc);
+    client.commit();
+  }
+
+  @Test
+  public void testQueryResponseWithJsonParser() throws Exception {
+    SolrQuery q = new SolrQuery("id:1");
+    q.addFacetField("name_s");
+    QueryRequest req = new QueryRequest(q);
+    req.setResponseParser(new CanonicalJsonResponseParser());
+
+    QueryResponse rsp = req.process(solrTestRule.getSolrClient());
+
+    // Header getters cast the values they read, and the JSON writer emits 
Long where javabin emits
+    // Integer.
+    assertEquals(0, rsp.getStatus());
+    assertNotNull(rsp.getResponseHeader());
+
+    // A facet section is a NamedList; under the default json.nl=flat it 
arrives as an array of
+    // alternating names and values, which cannot be recovered.
+    assertNotNull("facet_counts must be readable", 
rsp.getFacetField("name_s"));
+
+    // The documents section has to arrive as a SolrDocumentList for 
getResults() to work at all.
+    assertEquals(1, rsp.getResults().getNumFound());
+    assertEquals("1", rsp.getResults().get(0).getFirstValue("id"));
+  }
+
+  /**
+   * A named nested document has to come back as a document rather than a 
plain map, matching what
+   * the binary and XML parsers produce for the same response.
+   */
+  @Test
+  public void testNamedNestedDocumentsWithJsonParser() throws Exception {
+    SolrClient client = solrTestRule.getSolrClient();
+
+    SolrInputDocument child = new SolrInputDocument();
+    child.addField("id", "20");
+    child.addField("name_s", "a comment");
+
+    SolrInputDocument parent = new SolrInputDocument();
+    parent.addField("id", "10");
+    parent.addField("name_s", "a parent");
+    parent.addField("comment", child);
+
+    client.add(parent);
+    client.commit();
+
+    SolrQuery q = new SolrQuery("id:10");
+    q.setFields("*", "[child]");
+    QueryRequest req = new QueryRequest(q);
+    req.setResponseParser(new CanonicalJsonResponseParser());
+
+    QueryResponse rsp = req.process(client);
+
+    SolrDocument doc = rsp.getResults().get(0);
+    Object comment = doc.getFieldValue("comment");
+    assertNotNull("the named child must be present", comment);
+    assertTrue(
+        "a named child must be a SolrDocument, not " + 
comment.getClass().getName(),
+        comment instanceof SolrDocument);
+    assertEquals("a comment", ((SolrDocument) 
comment).getFirstValue("name_s"));
+  }
+}
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java 
b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
index 8c6e3b9ed18..35291fb6cce 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
@@ -52,6 +52,7 @@ import org.apache.solr.common.params.CommonParams;
 import org.apache.solr.common.params.CoreAdminParams;
 import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.params.ShardParams;
+import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.util.NamedList;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -151,10 +152,15 @@ public abstract class HttpSolrClient extends SolrClient {
 
   protected ModifiableSolrParams initializeSolrParams(
       SolrRequest<?> solrRequest, ResponseParser parserToUse) {
-    // The parser 'wt=' param is used instead of the original params
-    ModifiableSolrParams wparams = new 
ModifiableSolrParams(solrRequest.getParams());
-    wparams.set(CommonParams.WT, parserToUse.getWriterType());
-    return wparams;
+
+    // The parser's own params take precedence over the request's, as wt does.
+    var params =
+        new ModifiableSolrParams(
+            SolrParams.wrapDefaults(
+                parserToUse.getAdditionalRequestParams(), 
solrRequest.getParams()));
+    // set() removes the param when the writer type is null, which is how a 
parser asks for no wt.
+    params.set(CommonParams.WT, parserToUse.getWriterType());
+    return params;
   }
 
   protected boolean isMultipart(RequestWriter.ContentWriter contentWriter) {
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/AnalysisResponseBase.java
 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/AnalysisResponseBase.java
index f458bdc01c9..5c6f3851826 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/AnalysisResponseBase.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/AnalysisResponseBase.java
@@ -117,9 +117,9 @@ public class AnalysisResponseBase extends SolrResponseBase {
     String text = (String) tokenNL.get("text");
     String rawText = (String) tokenNL.get("rawText");
     String type = (String) tokenNL.get("type");
-    int start = (Integer) tokenNL.get("start");
-    int end = (Integer) tokenNL.get("end");
-    int position = (Integer) tokenNL.get("position");
+    int start = ((Number) tokenNL.get("start")).intValue();
+    int end = ((Number) tokenNL.get("end")).intValue();
+    int position = ((Number) tokenNL.get("position")).intValue();
     Boolean match = (Boolean) tokenNL.get("match");
     return new TokenInfo(
         text, rawText, type, start, end, position, (match == null ? false : 
match));
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/LukeResponse.java 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/LukeResponse.java
index f23bb29cffa..bfe75abb489 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/LukeResponse.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/LukeResponse.java
@@ -139,7 +139,7 @@ public class LukeResponse extends SolrResponseBase {
         } else if ("docs".equals(entry.getKey())) {
           docs = ((Number) entry.getValue()).longValue();
         } else if ("distinct".equals(entry.getKey())) {
-          distinct = (Integer) entry.getValue();
+          distinct = ((Number) entry.getValue()).intValue();
         } else if ("cacheableFaceting".equals(entry.getKey())) {
           cacheableFaceting = (Boolean) entry.getValue();
         } else if ("topTerms".equals(entry.getKey())) {
@@ -290,7 +290,8 @@ public class LukeResponse extends SolrResponseBase {
 
   public Integer getMaxDoc() {
     if (indexInfo == null) return null;
-    return (Integer) indexInfo.get("maxDoc");
+    Object v = indexInfo.get("maxDoc");
+    return v == null ? null : ((Number) v).intValue();
   }
 
   public Long getDeletedDocs() {
@@ -299,7 +300,8 @@ public class LukeResponse extends SolrResponseBase {
 
   public Integer getNumTerms() {
     if (indexInfo == null) return null;
-    return (Integer) indexInfo.get("numTerms");
+    Object v = indexInfo.get("numTerms");
+    return v == null ? null : ((Number) v).intValue();
   }
 
   public Map<String, FieldTypeInfo> getFieldTypeInfo() {
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java
index 392d6180122..3e9e472f359 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java
@@ -248,11 +248,11 @@ public class QueryResponse extends SolrResponseBase {
         }
 
         if (oGroups != null) {
-          Integer iMatches = (Integer) oMatches;
+          int iMatches = ((Number) oMatches).intValue();
           ArrayList<Object> groupsArr = (ArrayList<Object>) oGroups;
           GroupCommand groupedCommand;
           if (oNGroups != null) {
-            Integer iNGroups = (Integer) oNGroups;
+            int iNGroups = ((Number) oNGroups).intValue();
             groupedCommand = new GroupCommand(fieldName, iMatches, iNGroups);
           } else {
             groupedCommand = new GroupCommand(fieldName, iMatches);
@@ -269,10 +269,10 @@ public class QueryResponse extends SolrResponseBase {
 
           _groupResponse.add(groupedCommand);
         } else if (queryCommand != null) {
-          Integer iMatches = (Integer) oMatches;
+          int iMatches = ((Number) oMatches).intValue();
           GroupCommand groupCommand;
           if (oNGroups != null) {
-            Integer iNGroups = (Integer) oNGroups;
+            int iNGroups = ((Number) oNGroups).intValue();
             groupCommand = new GroupCommand(fieldName, iMatches, iNGroups);
           } else {
             groupCommand = new GroupCommand(fieldName, iMatches);
@@ -302,10 +302,10 @@ public class QueryResponse extends SolrResponseBase {
   private void extractFacetInfo(NamedList<Object> info) {
     // Parse the queries
     _facetQuery = new LinkedHashMap<>();
-    NamedList<Integer> fq = (NamedList<Integer>) info.get("facet_queries");
+    NamedList<Number> fq = (NamedList<Number>) info.get("facet_queries");
     if (fq != null) {
-      for (Map.Entry<String, Integer> entry : fq) {
-        _facetQuery.put(entry.getKey(), entry.getValue());
+      for (Map.Entry<String, Number> entry : fq) {
+        _facetQuery.put(entry.getKey(), entry.getValue().intValue());
       }
     }
 
@@ -354,7 +354,9 @@ public class QueryResponse extends SolrResponseBase {
         List<IntervalFacet.Count> counts =
             new 
ArrayList<IntervalFacet.Count>(intervalField.getValue().size());
         for (Map.Entry<String, Object> interval : intervalField.getValue()) {
-          counts.add(new IntervalFacet.Count(interval.getKey(), (Integer) 
interval.getValue()));
+          counts.add(
+              new IntervalFacet.Count(
+                  interval.getKey(), ((Number) 
interval.getValue()).intValue()));
         }
         _intervalFacets.add(new IntervalFacet(field, counts));
       }
@@ -401,9 +403,9 @@ public class QueryResponse extends SolrResponseBase {
             new RangeFacet.Currency(facet.getKey(), start, end, gap, before, 
after, between);
       }
 
-      NamedList<Integer> counts = (NamedList<Integer>) values.get("counts");
-      for (Map.Entry<String, Integer> entry : counts) {
-        rangeFacet.addCount(entry.getKey(), entry.getValue());
+      NamedList<Number> counts = (NamedList<Number>) values.get("counts");
+      for (Map.Entry<String, Number> entry : counts) {
+        rangeFacet.addCount(entry.getKey(), entry.getValue().intValue());
       }
 
       facetRanges.add(rangeFacet);
@@ -433,7 +435,7 @@ public class QueryResponse extends SolrResponseBase {
         switch (key) {
           case "field" -> field = (String) val;
           case "value" -> value = val;
-          case "count" -> count = ((Integer) val).intValue();
+          case "count" -> count = ((Number) val).intValue();
           case "pivot" -> {
             assert null != val : "Server sent back 'null' for sub pivots?";
             assert val instanceof List : "Server sent non-List for sub 
pivots?";
@@ -447,10 +449,10 @@ public class QueryResponse extends SolrResponseBase {
           case "queries" -> {
             // Parse the queries
             queryCounts = new LinkedHashMap<>();
-            NamedList<Integer> fq = (NamedList<Integer>) val;
+            NamedList<Number> fq = (NamedList<Number>) val;
             if (fq != null) {
-              for (Map.Entry<String, Integer> e : fq) {
-                queryCounts.put(e.getKey(), e.getValue());
+              for (Map.Entry<String, Number> e : fq) {
+                queryCounts.put(e.getKey(), e.getValue().intValue());
               }
             }
           }
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseCanonicalizer.java
 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseCanonicalizer.java
new file mode 100644
index 00000000000..a5c8a389043
--- /dev/null
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseCanonicalizer.java
@@ -0,0 +1,148 @@
+/*
+ * 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.response;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.common.params.CommonParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SimpleOrderedMap;
+
+/**
+ * Converts a parsed response into the canonical shape the SolrJ response 
classes expect (the shape
+ * the binary and XML parsers produce): nested JSON objects become {@link 
NamedList}s and a {@code
+ * {numFound, docs}} object becomes a {@link SolrDocumentList}.
+ *
+ * <p>Only unambiguous, self-describing conversions are performed. It is a 
no-op for values already
+ * in canonical form (so binary/XML responses pass through unchanged). It does 
not attempt to
+ * interpret the ambiguous flat arrays produced by {@code json.nl=flat}; a 
typed JSON parser should
+ * request {@code json.nl=map} for its own reads.
+ *
+ * <p>Public only so that {@link
+ * org.apache.solr.client.solrj.response.json.CanonicalJsonResponseParser}, in 
another package, can
+ * call it from its own {@code processResponse}; it is not intended for 
callers.
+ *
+ * @lucene.internal
+ */
+public final class ResponseCanonicalizer {
+
+  private ResponseCanonicalizer() {}
+
+  /** Returns a canonical copy of the given response NamedList. */
+  public static NamedList<Object> canonicalize(NamedList<Object> response) {
+    if (response == null) {
+      return null;
+    }
+    SimpleOrderedMap<Object> out = new SimpleOrderedMap<>(response.size());
+    for (Map.Entry<String, Object> e : response) {
+      out.add(e.getKey(), canonicalizeValue(e.getValue()));
+    }
+    return out;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Object canonicalizeValue(Object val) {
+    if (val instanceof SolrDocumentList || val instanceof SolrDocument) {
+      // Already canonical (binary/XML produce these directly); leave 
untouched. Must precede the
+      // List/Map branches since SolrDocumentList is a List and SolrDocument 
is a Map.
+      return val;
+    } else if (val instanceof NamedList<?> in) {
+      // Already canonical (binary/XML), but its children may still need 
canonicalizing. Keep the
+      // concrete type: a SimpleOrderedMap asserts unique keys, which a 
general NamedList does not,
+      // so promoting one to the other would change the contract of the value.
+      NamedList<Object> out =
+          in instanceof SimpleOrderedMap<?>
+              ? new SimpleOrderedMap<>(in.size())
+              : new NamedList<>(in.size());
+      for (Map.Entry<String, ?> e : in) {
+        out.add(e.getKey(), canonicalizeValue(e.getValue()));
+      }
+      return out;
+    } else if (val instanceof Map<?, ?> raw) {
+      Map<String, Object> m = (Map<String, Object>) raw;
+      if (isDocList(m)) {
+        return toDocList(m);
+      }
+      if (isNestedDoc(m)) {
+        return toDoc(m);
+      }
+      // A JSON object has unique keys by construction, so it maps onto 
SimpleOrderedMap.
+      SimpleOrderedMap<Object> out = new SimpleOrderedMap<>(m.size());
+      for (Map.Entry<String, Object> e : m.entrySet()) {
+        out.add(e.getKey(), canonicalizeValue(e.getValue()));
+      }
+      return out;
+    } else if (val instanceof List<?> in) {
+      List<Object> out = new ArrayList<>(in.size());
+      for (Object item : in) {
+        out.add(canonicalizeValue(item));
+      }
+      return out;
+    }
+    return val;
+  }
+
+  private static boolean isDocList(Map<String, Object> m) {
+    return m.get("numFound") instanceof Number && m.get("docs") instanceof 
List;
+  }
+
+  private static boolean isNestedDoc(Map<String, Object> m) {
+    return m.containsKey("_nest_path_") || m.containsKey("_nest_parent_");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static SolrDocumentList toDocList(Map<String, Object> m) {
+    SolrDocumentList docs = new SolrDocumentList();
+    docs.setNumFound(((Number) m.get("numFound")).longValue());
+    if (m.get("start") instanceof Number start) {
+      docs.setStart(start.longValue());
+    }
+    if (m.get("maxScore") instanceof Number maxScore) {
+      docs.setMaxScore(maxScore.floatValue());
+    }
+    if (m.get("numFoundExact") instanceof Boolean exact) {
+      docs.setNumFoundExact(exact);
+    }
+    for (Object d : (List<Object>) m.get("docs")) {
+      docs.add(toDoc(d));
+    }
+    return docs;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static SolrDocument toDoc(Object o) {
+    SolrDocument doc = new SolrDocument();
+    if (o instanceof Map) {
+      for (Map.Entry<String, Object> f : ((Map<String, Object>) o).entrySet()) 
{
+        if (CommonParams.CHILDDOC.equals(f.getKey()) && f.getValue() 
instanceof List<?> kids) {
+          // JSON has no document type, so nested documents arrive as a field 
holding a list of
+          // maps. The other parsers hand them back as child documents, so 
this one does too.
+          for (Object kid : kids) {
+            doc.addChildDocument(toDoc(kid));
+          }
+          continue;
+        }
+        // The value may be a reconstructed SolrDocumentList, which addField 
would unwrap.
+        doc.setField(f.getKey(), canonicalizeValue(f.getValue()));
+      }
+    }
+    return doc;
+  }
+}
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseParser.java 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseParser.java
index 47dfdccedee..aa465ad29e9 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseParser.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseParser.java
@@ -21,6 +21,7 @@ import java.io.InputStream;
 import java.util.Collection;
 import java.util.Locale;
 import java.util.Set;
+import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.util.NamedList;
 
 /**
@@ -51,6 +52,20 @@ public abstract class ResponseParser {
   /** The writer type placed onto the request as the {@code wt} param. */
   public abstract String getWriterType(); // for example: wt=XML, JSON, etc
 
+  /**
+   * Params this parser requires on the request in order to read the response, 
applied alongside
+   * {@code wt}.
+   *
+   * <p>These take precedence over the request's own params, as {@code wt} 
does: a parser that
+   * cannot read the form the caller asked for would fail rather than honour 
it. The JSON map parser
+   * requires {@code json.nl=map}, since a NamedList written any other way 
cannot be reconstructed.
+   *
+   * @return the params to apply, or null if the parser needs nothing beyond 
{@code wt}
+   */
+  public SolrParams getAdditionalRequestParams() {
+    return null;
+  }
+
   public abstract NamedList<Object> processResponse(InputStream body, String 
encoding)
       throws IOException;
 
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/SolrResponseBase.java
 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/SolrResponseBase.java
index 9d90184ce42..86f883b2c78 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/SolrResponseBase.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/SolrResponseBase.java
@@ -92,7 +92,9 @@ public class SolrResponseBase extends SolrResponse implements 
MapWriter {
   public int getStatus() {
     NamedList<?> header = getResponseHeader();
     if (header != null) {
-      return (Integer) header.get("status");
+      // ResponseParsers vary in the numeric type they produce (e.g. JSON 
yields Long), so widen
+      // via Number rather than casting to Integer.  See SOLR-17316.
+      return ((Number) header.get("status")).intValue();
     } else {
       return 0;
     }
@@ -101,7 +103,7 @@ public class SolrResponseBase extends SolrResponse 
implements MapWriter {
   public int getQTime() {
     NamedList<?> header = getResponseHeader();
     if (header != null) {
-      return (Integer) header.get("QTime");
+      return ((Number) header.get("QTime")).intValue();
     } else {
       return 0;
     }
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/SpellCheckResponse.java
 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/SpellCheckResponse.java
index 4d3a077da51..ca6c056b842 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/SpellCheckResponse.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/SpellCheckResponse.java
@@ -144,10 +144,10 @@ public class SpellCheckResponse {
       suggestion.forEach(
           (n, val) -> {
             switch (n) {
-              case "numFound" -> numFound = (Integer) val;
-              case "startOffset" -> startOffset = (Integer) val;
-              case "endOffset" -> endOffset = (Integer) val;
-              case "origFreq" -> originalFrequency = (Integer) val;
+              case "numFound" -> numFound = ((Number) val).intValue();
+              case "startOffset" -> startOffset = ((Number) val).intValue();
+              case "endOffset" -> endOffset = ((Number) val).intValue();
+              case "origFreq" -> originalFrequency = ((Number) val).intValue();
               case "suggestion" -> {
                 List<?> list = (List<?>) val;
                 if (!list.isEmpty() && list.get(0) instanceof NamedList) {
@@ -157,7 +157,7 @@ public class SpellCheckResponse {
                   alternativeFrequencies = new ArrayList<>();
                   for (NamedList<?> nl : extended) {
                     alternatives.add((String) nl.get("word"));
-                    alternativeFrequencies.add((Integer) nl.get("freq"));
+                    alternativeFrequencies.add(((Number) 
nl.get("freq")).intValue());
                   }
                 } else {
                   @SuppressWarnings("unchecked")
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/json/CanonicalJsonResponseParser.java
 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/json/CanonicalJsonResponseParser.java
new file mode 100644
index 00000000000..25d35fae6d1
--- /dev/null
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/json/CanonicalJsonResponseParser.java
@@ -0,0 +1,56 @@
+/*
+ * 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.response.json;
+
+import java.io.IOException;
+import java.io.InputStream;
+import org.apache.solr.client.solrj.response.ResponseCanonicalizer;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.JsonTextWriter;
+import org.apache.solr.common.util.NamedList;
+
+/**
+ * A JSON parser that converts the response to the canonical shape SolrJ's 
response objects expect
+ * -- {@link NamedList} trees with {@link 
org.apache.solr.common.SolrDocumentList} for document
+ * sections -- so that {@code QueryResponse} and its siblings can read a JSON 
response. It also asks
+ * for {@code json.nl=map}, without which a {@code NamedList} cannot be 
reconstructed.
+ *
+ * <p>Callers that re-serialise the response or read its raw structure want 
{@link
+ * JsonMapResponseParser} instead; this conversion would change what they see.
+ */
+public class CanonicalJsonResponseParser extends JsonMapResponseParser {
+
+  private static final SolrParams CANONICAL_PARAMS =
+      SolrParams.of(JsonTextWriter.JSON_NL_STYLE, JsonTextWriter.JSON_NL_MAP);
+
+  /**
+   * Asks for {@code json.nl=map}, so that a {@link NamedList} written by the 
server arrives as a
+   * JSON object and {@link #processResponse} can restore it as a {@code 
NamedList}. Under the
+   * default {@code json.nl=flat} the keys and values are flattened into one 
array, and the
+   * structure cannot be recovered.
+   */
+  @Override
+  public SolrParams getAdditionalRequestParams() {
+    return CANONICAL_PARAMS;
+  }
+
+  @Override
+  public NamedList<Object> processResponse(InputStream body, String encoding) 
throws IOException {
+    return ResponseCanonicalizer.canonicalize(super.processResponse(body, 
encoding));
+  }
+}
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/schema/SchemaResponse.java
 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/schema/SchemaResponse.java
index e2ca9d835f1..f44cbd4364f 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/response/schema/SchemaResponse.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/response/schema/SchemaResponse.java
@@ -158,7 +158,8 @@ public class SchemaResponse extends SolrResponseBase {
   }
 
   private static Float getSchemaVersion(@SuppressWarnings({"rawtypes"}) Map 
schemaNamedList) {
-    return (Float) schemaNamedList.get("version");
+    Object v = schemaNamedList.get("version");
+    return v == null ? null : ((Number) v).floatValue();
   }
 
   private static String getSchemaUniqueKey(@SuppressWarnings({"rawtypes"}) Map 
schemaNamedList) {
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/SolrExampleJsonTest.java 
b/solr/solrj/src/test/org/apache/solr/client/solrj/SolrExampleJsonTest.java
new file mode 100644
index 00000000000..0e209de4e57
--- /dev/null
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/SolrExampleJsonTest.java
@@ -0,0 +1,32 @@
+/*
+ * 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 org.apache.solr.SolrTestCaseJ4.SuppressSSL;
+import org.apache.solr.client.solrj.response.json.CanonicalJsonResponseParser;
+
+/** Runs the example tests over {@link CanonicalJsonResponseParser}. */
+@SuppressSSL(bugUrl = "https://issues.apache.org/jira/browse/SOLR-5776";)
+public class SolrExampleJsonTest extends SolrExampleTests {
+  @Override
+  public SolrClient createNewSolrClient() {
+    return solrTestRule
+        .newSolrClientBuilder()
+        .withResponseParser(new CanonicalJsonResponseParser())
+        .build();
+  }
+}
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/SolrExampleTests.java 
b/solr/solrj/src/test/org/apache/solr/client/solrj/SolrExampleTests.java
index e4995805b3d..ab0860f0b5e 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/SolrExampleTests.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/SolrExampleTests.java
@@ -779,12 +779,12 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
     SolrDocument out2 = out.get(1);
     assertEquals("111", out1.getFieldValue("id"));
     assertEquals("222", out2.getFieldValue("id"));
-    assertEquals(1.0f, out1.getFieldValue("score"));
-    assertEquals(1.0f, out2.getFieldValue("score"));
+    assertEquals(1.0, ((Number) out1.getFieldValue("score")).doubleValue(), 
0.0);
+    assertEquals(1.0, ((Number) out2.getFieldValue("score")).doubleValue(), 
0.0);
 
     // check that the docid is one bigger
-    int id1 = (Integer) out1.getFieldValue("[docid]");
-    int id2 = (Integer) out2.getFieldValue("[docid]");
+    int id1 = ((Number) out1.getFieldValue("[docid]")).intValue();
+    int id2 = ((Number) out2.getFieldValue("[docid]")).intValue();
     assertTrue("should be bigger [" + id1 + "," + id2 + "]", id2 > id1);
 
     // The score from explain should be the same as the score
@@ -793,7 +793,7 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
 
     // Augmented _value_ with alias
     assertEquals("aaa", out1.get("aaa"));
-    assertEquals(10, ((Integer) out1.get("ten")).intValue());
+    assertEquals(10, ((Number) out1.get("ten")).intValue());
   }
 
   @Test
@@ -1883,7 +1883,7 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
     List<RangeFacet> list = rsp.getFacetRanges();
     assertEquals(2, list.size());
     @SuppressWarnings("unchecked")
-    RangeFacet<Float, Float> range1 = list.get(0);
+    RangeFacet<Number, Number> range1 = list.get(0);
     assertEquals("price1", range1.getName());
     assertEquals(0, range1.getStart().intValue());
     assertEquals(200, range1.getEnd().intValue());
@@ -1899,7 +1899,7 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
     assertEquals(0, counts1.get(3).getCount());
     assertEquals("150.0", counts1.get(3).getValue());
     @SuppressWarnings("unchecked")
-    RangeFacet<Float, Float> range2 = list.get(1);
+    RangeFacet<Number, Number> range2 = list.get(1);
     assertEquals("price2", range2.getName());
     assertEquals(0, range2.getStart().intValue());
     assertEquals(200, range2.getEnd().intValue());
@@ -1926,9 +1926,9 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
     for (RangeFacet range : featuresBBBRanges) {
       if (range.getName().equals("price1")) {
         assertNotNull(range);
-        assertEquals(0, ((Float) range.getStart()).intValue());
-        assertEquals(200, ((Float) range.getEnd()).intValue());
-        assertEquals(50, ((Float) range.getGap()).intValue());
+        assertEquals(0, ((Number) range.getStart()).intValue());
+        assertEquals(200, ((Number) range.getEnd()).intValue());
+        assertEquals(50, ((Number) range.getGap()).intValue());
         @SuppressWarnings({"unchecked"})
         List<Count> counts = range.getCounts();
         assertEquals(4, counts.size());
@@ -1950,9 +1950,9 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
         }
       } else if (range.getName().equals("price2")) {
         assertNotNull(range);
-        assertEquals(0, ((Float) range.getStart()).intValue());
-        assertEquals(200, ((Float) range.getEnd()).intValue());
-        assertEquals(50, ((Float) range.getGap()).intValue());
+        assertEquals(0, ((Number) range.getStart()).intValue());
+        assertEquals(200, ((Number) range.getEnd()).intValue());
+        assertEquals(50, ((Number) range.getGap()).intValue());
         @SuppressWarnings({"unchecked"})
         List<Count> counts = range.getCounts();
         assertEquals(4, counts.size());
@@ -1982,9 +1982,9 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
     for (RangeFacet range : facetRanges) {
       if (range.getName().equals("price1")) {
         assertNotNull(range);
-        assertEquals(0, ((Float) range.getStart()).intValue());
-        assertEquals(200, ((Float) range.getEnd()).intValue());
-        assertEquals(50, ((Float) range.getGap()).intValue());
+        assertEquals(0, ((Number) range.getStart()).intValue());
+        assertEquals(200, ((Number) range.getEnd()).intValue());
+        assertEquals(50, ((Number) range.getGap()).intValue());
         @SuppressWarnings({"unchecked"})
         List<Count> counts = range.getCounts();
         assertEquals(4, counts.size());
@@ -2006,9 +2006,9 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
         }
       } else if (range.getName().equals("price2")) {
         assertNotNull(range);
-        assertEquals(0, ((Float) range.getStart()).intValue());
-        assertEquals(200, ((Float) range.getEnd()).intValue());
-        assertEquals(50, ((Float) range.getGap()).intValue());
+        assertEquals(0, ((Number) range.getStart()).intValue());
+        assertEquals(200, ((Number) range.getEnd()).intValue());
+        assertEquals(50, ((Number) range.getGap()).intValue());
         @SuppressWarnings({"unchecked"})
         List<Count> counts = range.getCounts();
         assertEquals(4, counts.size());
@@ -2341,7 +2341,7 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
     assertEquals("Doc count does not match", 1, 
resp.getResults().getNumFound());
     Long version = (Long) resp.getResults().get(0).getFirstValue("_version_");
     assertNotNull("no version returned", version);
-    assertEquals(1.0f, resp.getResults().get(0).getFirstValue(field));
+    assertEquals(1.0, ((Number) 
resp.getResults().get(0).getFirstValue(field)).doubleValue(), 0.0);
 
     // update "price" with incorrect version (optimistic locking)
     HashMap<String, Object> oper = new HashMap<>(); // need better api for 
this???
@@ -2387,7 +2387,11 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
     client.commit();
     resp = client.query(q);
     assertEquals("Doc count does not match", 1, 
resp.getResults().getNumFound());
-    assertEquals("price was not updated?", 100.0f, 
resp.getResults().get(0).getFirstValue(field));
+    assertEquals(
+        "price was not updated?",
+        100.0,
+        ((Number) resp.getResults().get(0).getFirstValue(field)).doubleValue(),
+        0.0);
     assertEquals("no name?", "gadget", 
resp.getResults().get(0).getFirstValue("name"));
 
     // update "price", no version
@@ -2399,7 +2403,11 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
     client.commit();
     resp = client.query(q);
     assertEquals("Doc count does not match", 1, 
resp.getResults().getNumFound());
-    assertEquals("price was not updated?", 200.0f, 
resp.getResults().get(0).getFirstValue(field));
+    assertEquals(
+        "price was not updated?",
+        200.0,
+        ((Number) resp.getResults().get(0).getFirstValue(field)).doubleValue(),
+        0.0);
     assertEquals("no name?", "gadget", 
resp.getResults().get(0).getFirstValue("name"));
   }
 
@@ -2578,7 +2586,10 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
 
           for (SolrDocument kid : outDoc.getChildDocuments()) {
             String kidId = (String) kid.getFieldValue("id");
-            assertEquals("kid is the wrong level", kidLevel, (int) 
kid.getFieldValue("level_i"));
+            assertEquals(
+                "kid is the wrong level",
+                kidLevel,
+                ((Number) kid.getFieldValue("level_i")).intValue());
             SolrInputDocument origChild = findDescendant(origDoc, kidId);
             assertNotNull(docId + " doesn't have descendant " + kidId, 
origChild);
           }
@@ -2661,7 +2672,7 @@ public abstract class SolrExampleTests extends 
SolrExampleTestsBase {
           assertTrue("orig doc had no kids at all", 
origDoc.hasChildDocuments());
           for (SolrDocument kid : outDoc.getChildDocuments()) {
             String kidId = (String) kid.getFieldValue("id");
-            int kidLevel = (int) kid.getFieldValue("level_i");
+            int kidLevel = ((Number) kid.getFieldValue("level_i")).intValue();
             assertTrue(
                 "kid level to high: " + kidLevelMax + "<" + kidLevel, kidLevel 
<= kidLevelMax);
             assertTrue(
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/response/AdminResponseNumericTypeTest.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/AdminResponseNumericTypeTest.java
new file mode 100644
index 00000000000..7722a3ca8c6
--- /dev/null
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/AdminResponseNumericTypeTest.java
@@ -0,0 +1,105 @@
+/*
+ * 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.response;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.response.schema.SchemaResponse;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SimpleOrderedMap;
+import org.junit.Test;
+
+/**
+ * Non-binary parsers deliver integers as Long. These response classes widen 
via Number rather than
+ * casting to Integer/Long/Float, so a Long value must not throw (SOLR-17316). 
Each assertion fails
+ * with a ClassCastException without the widening.
+ */
+public class AdminResponseNumericTypeTest extends SolrTestCase {
+
+  /** AnalysisResponseBase.buildTokenInfo: start/end/position widened from 
Number. */
+  @Test
+  public void testAnalysisTokenInfo() {
+    NamedList<Object> token = new SimpleOrderedMap<>();
+    token.add("text", "foo");
+    token.add("start", 1L); // JSON yields Long
+    token.add("end", 4L);
+    token.add("position", 2L);
+
+    var probe =
+        new AnalysisResponseBase() {
+          TokenInfo build(NamedList<?> nl) {
+            return buildTokenInfo(nl);
+          }
+        };
+    AnalysisResponseBase.TokenInfo info = probe.build(token);
+    assertEquals(1, info.getStart());
+    assertEquals(4, info.getEnd());
+    assertEquals(2, info.getPosition());
+  }
+
+  /** LukeResponse.getMaxDoc/getNumTerms: widened from Number. */
+  @Test
+  public void testLukeIndexInfo() {
+    NamedList<Object> index = new SimpleOrderedMap<>();
+    index.add("maxDoc", 10L); // JSON yields Long
+    index.add("numTerms", 42L);
+    NamedList<Object> body = new SimpleOrderedMap<>();
+    body.add("index", index);
+
+    LukeResponse r = new LukeResponse();
+    r.setResponse(body);
+    assertEquals(Integer.valueOf(10), r.getMaxDoc());
+    assertEquals(Integer.valueOf(42), r.getNumTerms());
+  }
+
+  /** LukeResponse.FieldInfo.distinct: widened from Number. */
+  @Test
+  public void testLukeFieldDistinct() {
+    NamedList<Object> field = new SimpleOrderedMap<>();
+    field.add("type", "string");
+    field.add("distinct", 5L); // JSON yields Long
+    NamedList<Object> fields = new SimpleOrderedMap<>();
+    fields.add("cat", field);
+    NamedList<Object> body = new SimpleOrderedMap<>();
+    body.add("fields", fields);
+
+    LukeResponse r = new LukeResponse();
+    r.setResponse(body);
+    assertEquals(5, r.getFieldInfo("cat").getDistinct());
+  }
+
+  /** SchemaResponse.getSchemaVersion: widened from Number (JSON yields Double 
for 1.6). */
+  @Test
+  @SuppressWarnings({"unchecked", "rawtypes"})
+  public void testSchemaVersion() {
+    Map schema = new LinkedHashMap();
+    schema.put("version", 1.6d); // JSON yields Double
+    schema.put("fields", new ArrayList<>());
+    schema.put("dynamicFields", new ArrayList<>());
+    schema.put("fieldTypes", new ArrayList<>());
+    schema.put("copyFields", new ArrayList<>());
+    NamedList<Object> body = new SimpleOrderedMap<>();
+    body.add("schema", schema);
+
+    SchemaResponse r = new SchemaResponse();
+    r.setResponse(body);
+    Float version = r.getSchemaRepresentation().getVersion();
+    assertEquals(1.6f, version.floatValue(), 0.0001f);
+  }
+}
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseJsonParserIntegrationTest.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseJsonParserIntegrationTest.java
new file mode 100644
index 00000000000..d2c87ea06d9
--- /dev/null
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseJsonParserIntegrationTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.response;
+
+import static org.apache.solr.SolrTestCaseJ4.sdoc;
+
+import java.util.List;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.impl.HttpJdkSolrClient;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.response.json.CanonicalJsonResponseParser;
+import org.apache.solr.util.ExternalPaths;
+import org.apache.solr.util.SolrJettyTestRule;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/**
+ * End-to-end: a real HTTP query with {@link CanonicalJsonResponseParser} must 
return a fully typed
+ * QueryResponse, i.e. the parser's canonical form reaches the response 
classes over the client's
+ * response path (SOLR-17316).
+ */
+public class QueryResponseJsonParserIntegrationTest extends SolrTestCase {
+
+  @ClassRule public static SolrJettyTestRule solrTestRule = new 
SolrJettyTestRule();
+
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    System.setProperty("solr.security.allow.paths", "*");
+    solrTestRule.startSolr();
+    
solrTestRule.newCollection().withConfigSet(ExternalPaths.TECHPRODUCTS_CONFIGSET).create();
+
+    SolrClient client = solrTestRule.getSolrClient();
+    client.add(
+        List.of(
+            sdoc("id", "1", "cat", "electronics"),
+            sdoc("id", "2", "cat", "electronics"),
+            sdoc("id", "3", "cat", "books")));
+    client.commit();
+  }
+
+  /** The default (Jetty) transport. */
+  @Test
+  public void testTypedQueryResponseOverJsonJetty() throws Exception {
+    try (SolrClient client =
+        solrTestRule
+            .newSolrClientBuilder()
+            .withResponseParser(new CanonicalJsonResponseParser())
+            .build()) {
+      assertTypedResponse(client);
+    }
+  }
+
+  /** The JDK transport shares the same response boundary, so it must behave 
identically. */
+  @Test
+  public void testTypedQueryResponseOverJsonJdk() throws Exception {
+    try (SolrClient client =
+        new HttpJdkSolrClient.Builder(solrTestRule.getBaseUrl())
+            .withResponseParser(new CanonicalJsonResponseParser())
+            .build()) {
+      assertTypedResponse(client);
+    }
+  }
+
+  private void assertTypedResponse(SolrClient client) throws Exception {
+    SolrQuery q = new SolrQuery("*:*");
+    q.setRows(10);
+    q.addFacetField("cat");
+    // no json.nl here: the parser supplies the style it can read
+
+    QueryResponse rsp = client.query("collection1", q);
+
+    assertEquals(0, rsp.getStatus());
+    assertEquals(3, rsp.getResults().getNumFound());
+    assertNotNull(rsp.getResults().get(0).getFirstValue("id"));
+
+    FacetField cat = rsp.getFacetField("cat");
+    assertNotNull("facet field cat", cat);
+    assertEquals(2, cat.getValueCount());
+  }
+}
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseSectionParityTest.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseSectionParityTest.java
new file mode 100644
index 00000000000..efd1a8bcca1
--- /dev/null
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseSectionParityTest.java
@@ -0,0 +1,177 @@
+/*
+ * 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.response;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.response.json.JsonMapResponseParser;
+import org.apache.solr.common.util.NamedList;
+import org.junit.Test;
+
+/**
+ * Each test feeds a JSON (json.nl=map) response for one QueryResponse section 
through the
+ * canonicalizer and asserts the typed accessor works. Sections with a numeric 
cast (grouping,
+ * facets, spellcheck) also guard the Number widening; the rest guard the 
structural Map -&gt;
+ * NamedList / SolrDocumentList reconstruction the section relies on.
+ */
+public class QueryResponseSectionParityTest extends SolrTestCase {
+
+  private static QueryResponse parse(String json) throws Exception {
+    NamedList<Object> parsed =
+        new JsonMapResponseParser()
+            .processResponse(
+                new 
ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), "UTF-8");
+    QueryResponse r = new QueryResponse();
+    r.setResponse(ResponseCanonicalizer.canonicalize(parsed));
+    return r;
+  }
+
+  private static final String HEADER =
+      """
+      "responseHeader":{"status":0,"QTime":1},""";
+
+  /** pivot facets: count is an Integer cast (QueryResponse readPivots). */
+  @Test
+  public void testPivotFacets() throws Exception {
+    String json =
+        "{"
+            + HEADER
+            + """
+            "facet_counts":{"facet_queries":{},"facet_fields":{},
+            
"facet_pivot":{"cat":[{"field":"cat","value":"electronics","count":3}]}}}""";
+    QueryResponse r = parse(json);
+    assertNotNull("facetPivot", r.getFacetPivot());
+    assertEquals(3, r.getFacetPivot().get("cat").get(0).getCount());
+  }
+
+  /** grouping: matches / ngroups are Integer casts (QueryResponse 
extractGroupedInfo). */
+  @Test
+  public void testGrouping() throws Exception {
+    String json =
+        "{"
+            + HEADER
+            + """
+            "grouped":{"cat":{"matches":3,"ngroups":2,"groups":[
+            
{"groupValue":"a","doclist":{"numFound":2,"start":0,"docs":[{"id":"1"}]}},
+            
{"groupValue":"b","doclist":{"numFound":1,"start":0,"docs":[{"id":"2"}]}}
+            ]}}}""";
+    QueryResponse r = parse(json);
+    GroupResponse gr = r.getGroupResponse();
+    assertNotNull("groupResponse", gr);
+    assertEquals(3, gr.getValues().get(0).getMatches());
+    assertEquals(Integer.valueOf(2), gr.getValues().get(0).getNGroups());
+  }
+
+  /** interval facets: count is an Integer cast (QueryResponse 
extractFacetInfo). */
+  @Test
+  public void testIntervalFacets() throws Exception {
+    String json =
+        "{"
+            + HEADER
+            + """
+            "facet_counts":{"facet_queries":{},"facet_fields":{},
+            "facet_intervals":{"price":{"[0,10]":5,"[11,100]":3}}}}""";
+    QueryResponse r = parse(json);
+    assertNotNull("intervalFacets", r.getIntervalFacets());
+    assertEquals(2, r.getIntervalFacets().get(0).getIntervals().size());
+    assertEquals(5, 
r.getIntervalFacets().get(0).getIntervals().get(0).getCount());
+  }
+
+  /** field stats: count/missing (Long) and sumOfSquares/stddev (Double) casts 
(FieldStatsInfo). */
+  @Test
+  public void testFieldStats() throws Exception {
+    String json =
+        "{"
+            + HEADER
+            + """
+            "stats":{"stats_fields":{"price":{
+            "min":9.0,"max":12.0,"count":2,"missing":0,
+            
"sumOfSquares":225.0,"stddev":1.5,"countDistinct":2,"cardinality":2}}}}""";
+    QueryResponse r = parse(json);
+    assertNotNull("fieldStatsInfo", r.getFieldStatsInfo());
+    FieldStatsInfo price = r.getFieldStatsInfo().get("price");
+    assertNotNull("price stats", price);
+    assertEquals(Long.valueOf(2), price.getCount());
+    assertEquals(Long.valueOf(0), price.getMissing());
+    assertEquals(Double.valueOf(1.5), price.getStddev());
+    assertEquals(Long.valueOf(2), price.getCardinality());
+  }
+
+  /** spellcheck: numFound / startOffset / origFreq are Integer casts 
(SpellCheckResponse). */
+  @Test
+  public void testSpellCheck() throws Exception {
+    String json =
+        "{"
+            + HEADER
+            + """
+            "spellcheck":{"suggestions":{
+            "helo":{"numFound":1,"startOffset":0,"endOffset":4,"origFreq":0,
+            "suggestion":[{"word":"hello","freq":5}]}}}}""";
+    QueryResponse r = parse(json);
+    SpellCheckResponse sc = r.getSpellCheckResponse();
+    assertNotNull("spellcheck", sc);
+    SpellCheckResponse.Suggestion s = sc.getSuggestion("helo");
+    assertNotNull("suggestion", s);
+    assertEquals(1, s.getNumFound());
+    assertEquals(0, s.getStartOffset());
+    assertEquals(Integer.valueOf(5), s.getAlternativeFrequencies().get(0));
+  }
+
+  /** highlighting: no numeric cast, but exercises Map->NamedList 
reconstruction over JSON. */
+  @Test
+  public void testHighlighting() throws Exception {
+    String json =
+        "{"
+            + HEADER
+            + """
+            "highlighting":{"1":{"name":["<em>foo</em>"]}}}""";
+    QueryResponse r = parse(json);
+    assertNotNull("highlighting", r.getHighlighting());
+    assertEquals("<em>foo</em>", 
r.getHighlighting().get("1").get("name").get(0));
+  }
+
+  /** terms: df/ttf are read via Number, and the section is a nested NamedList 
over JSON. */
+  @Test
+  public void testTerms() throws Exception {
+    String json =
+        "{"
+            + HEADER
+            + """
+            "terms":{"cat":{"electronics":3,"books":1}}}""";
+    QueryResponse r = parse(json);
+    assertNotNull("termsResponse", r.getTermsResponse());
+    assertEquals(2, r.getTermsResponse().getTerms("cat").size());
+    assertEquals(3L, 
r.getTermsResponse().getTerms("cat").get(0).getFrequency());
+  }
+
+  /**
+   * moreLikeThis: each value is a {numFound,docs} object -> must reconstruct 
as SolrDocumentList.
+   */
+  @Test
+  public void testMoreLikeThis() throws Exception {
+    String json =
+        "{"
+            + HEADER
+            + """
+            
"moreLikeThis":{"1":{"numFound":1,"start":0,"docs":[{"id":"2"}]}}}""";
+    QueryResponse r = parse(json);
+    assertNotNull("moreLikeThis", r.getMoreLikeThis());
+    assertEquals(1, r.getMoreLikeThis().get("1").getNumFound());
+    assertEquals("2", r.getMoreLikeThis().get("1").get(0).getFirstValue("id"));
+  }
+}
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseCanonicalizerTest.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseCanonicalizerTest.java
new file mode 100644
index 00000000000..306cb54fa60
--- /dev/null
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseCanonicalizerTest.java
@@ -0,0 +1,351 @@
+/*
+ * 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.response;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.common.params.CommonParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SimpleOrderedMap;
+import org.junit.Test;
+
+/** Intensive tests for {@link ResponseCanonicalizer}. */
+public class ResponseCanonicalizerTest extends SolrTestCase {
+
+  @Test
+  public void testNullAndEmpty() {
+    assertNull(ResponseCanonicalizer.canonicalize(null));
+    assertEquals(0, ResponseCanonicalizer.canonicalize(new 
NamedList<>()).size());
+  }
+
+  @Test
+  public void testAlreadyCanonicalPassesThrough() {
+    NamedList<Object> header = new SimpleOrderedMap<>();
+    header.add("status", 0);
+    NamedList<Object> in = new SimpleOrderedMap<>();
+    in.add("responseHeader", header);
+
+    NamedList<Object> out = ResponseCanonicalizer.canonicalize(in);
+    assertTrue(out.get("responseHeader") instanceof NamedList);
+    assertEquals(0, ((NamedList<?>) out.get("responseHeader")).get("status"));
+  }
+
+  @Test
+  public void testMapBecomesNamedListRecursively() {
+    Map<String, Object> inner = new LinkedHashMap<>();
+    inner.put("a", 1);
+    Map<String, Object> mid = new LinkedHashMap<>();
+    mid.put("inner", inner);
+    NamedList<Object> in = new NamedList<>();
+    in.add("mid", mid);
+
+    NamedList<Object> out = ResponseCanonicalizer.canonicalize(in);
+    Object midOut = out.get("mid");
+    assertTrue("mid should be NamedList", midOut instanceof NamedList);
+    Object innerOut = ((NamedList<?>) midOut).get("inner");
+    assertTrue("inner should be NamedList", innerOut instanceof NamedList);
+    assertEquals(1, ((NamedList<?>) innerOut).get("a"));
+  }
+
+  @Test
+  public void testDocListReconstruction() {
+    Map<String, Object> doc1 = new LinkedHashMap<>();
+    doc1.put("id", "1");
+    Map<String, Object> response = new LinkedHashMap<>();
+    response.put("numFound", 5L);
+    response.put("start", 0L);
+    response.put("maxScore", 1.5);
+    response.put("numFoundExact", false);
+    response.put("docs", new ArrayList<>(List.of(doc1)));
+    NamedList<Object> in = new NamedList<>();
+    in.add("response", response);
+
+    NamedList<Object> out = ResponseCanonicalizer.canonicalize(in);
+    Object r = out.get("response");
+    assertTrue("response should be SolrDocumentList", r instanceof 
SolrDocumentList);
+    SolrDocumentList docs = (SolrDocumentList) r;
+    assertEquals(5L, docs.getNumFound());
+    assertEquals(0L, docs.getStart());
+    assertEquals(Float.valueOf(1.5f), docs.getMaxScore());
+    assertFalse("numFoundExact must survive the conversion", 
docs.getNumFoundExact());
+    assertEquals(1, docs.size());
+    assertEquals("1", docs.get(0).getFirstValue("id"));
+  }
+
+  @Test
+  public void testEmptyDocList() {
+    Map<String, Object> response = new LinkedHashMap<>();
+    response.put("numFound", 0L);
+    response.put("docs", new ArrayList<>());
+    NamedList<Object> in = new NamedList<>();
+    in.add("response", response);
+
+    SolrDocumentList docs =
+        (SolrDocumentList) 
ResponseCanonicalizer.canonicalize(in).get("response");
+    assertEquals(0L, docs.getNumFound());
+    assertTrue(docs.isEmpty());
+  }
+
+  @Test
+  public void testDocListValuedFieldIsReconstructed() {
+    // a doc field whose value is itself a {numFound,docs} object becomes a 
nested SolrDocumentList
+    Map<String, Object> child = new LinkedHashMap<>();
+    child.put("id", "child-1");
+    Map<String, Object> childList = new LinkedHashMap<>();
+    childList.put("numFound", 1L);
+    childList.put("docs", new ArrayList<>(List.of(child)));
+
+    Map<String, Object> parent = new LinkedHashMap<>();
+    parent.put("id", "parent-1");
+    parent.put("nested", childList);
+
+    Map<String, Object> response = new LinkedHashMap<>();
+    response.put("numFound", 1L);
+    response.put("docs", new ArrayList<>(List.of(parent)));
+    NamedList<Object> in = new NamedList<>();
+    in.add("response", response);
+
+    SolrDocumentList docs =
+        (SolrDocumentList) 
ResponseCanonicalizer.canonicalize(in).get("response");
+    SolrDocument parentDoc = docs.get(0);
+    Object nested = parentDoc.getFieldValue("nested");
+    assertTrue("nested docList field reconstructed", nested instanceof 
SolrDocumentList);
+    assertEquals("child-1", ((SolrDocumentList) 
nested).get(0).getFirstValue("id"));
+  }
+
+  /**
+   * A nested-document schema stamps every child with {@code _nest_path_}, and 
{@code [child]}
+   * returns it under {@code fl=*}, so a named child says what it is. The 
shapes here are the ones a
+   * live response carries: a single child under its own field name, an array 
of children under
+   * theirs, and a grandchild inside the single child. The binary and XML 
parsers hand all three
+   * back as documents ({@code <doc name="lonely">} in XML), so this one must 
too.
+   */
+  @Test
+  public void testNamedNestedDocumentsAreReconstructed() {
+    Map<String, Object> grandChild = new LinkedHashMap<>();
+    grandChild.put("id", "3");
+    grandChild.put("test2_s", "secondTest");
+    grandChild.put("_nest_path_", "/lonely#/lonelyGrandChild#");
+
+    Map<String, Object> lonely = new LinkedHashMap<>();
+    lonely.put("id", "2");
+    lonely.put("test_s", "testing");
+    lonely.put("_nest_path_", "/lonely#");
+    lonely.put("lonelyGrandChild", grandChild);
+
+    Map<String, Object> topping = new LinkedHashMap<>();
+    topping.put("id", "4");
+    topping.put("type_s", "Regular");
+    topping.put("_nest_path_", "/toppings#0");
+
+    Map<String, Object> parent = new LinkedHashMap<>();
+    parent.put("id", "1");
+    parent.put("lonely", lonely);
+    parent.put("toppings", new ArrayList<>(List.of(topping)));
+
+    Map<String, Object> response = new LinkedHashMap<>();
+    response.put("numFound", 1L);
+    response.put("docs", new ArrayList<>(List.of(parent)));
+    NamedList<Object> in = new NamedList<>();
+    in.add("response", response);
+
+    SolrDocument parentDoc =
+        ((SolrDocumentList) 
ResponseCanonicalizer.canonicalize(in).get("response")).get(0);
+
+    Object single = parentDoc.getFieldValue("lonely");
+    assertTrue("a named child must be a SolrDocument, not a map", single 
instanceof SolrDocument);
+    assertEquals("testing", ((SolrDocument) single).getFirstValue("test_s"));
+
+    Object nestedGrandChild = ((SolrDocument) 
single).getFieldValue("lonelyGrandChild");
+    assertTrue("a grandchild must be reconstructed too", nestedGrandChild 
instanceof SolrDocument);
+
+    Object array = parentDoc.getFieldValue("toppings");
+    assertTrue("a named child array stays a List", array instanceof List);
+    assertTrue(
+        "its elements must be SolrDocuments", ((List<?>) array).get(0) 
instanceof SolrDocument);
+
+    // Named children are field values, not child documents -- the same as 
binary and XML, where
+    // ChildDocTransformer calls setField for a named path and 
addChildDocuments only for anonymous.
+    assertFalse(
+        "a named child is a field value, so the parent has no child documents",
+        parentDoc.hasChildDocuments());
+  }
+
+  /** An unmarked object stays a map: most map-valued fields in a response are 
not documents. */
+  @Test
+  public void testUnmarkedObjectIsNotPromotedToDocument() {
+    Map<String, Object> notADoc = new LinkedHashMap<>();
+    notADoc.put("id", "2");
+    notADoc.put("test_s", "testing");
+
+    Map<String, Object> parent = new LinkedHashMap<>();
+    parent.put("id", "1");
+    parent.put("someStruct", notADoc);
+
+    Map<String, Object> response = new LinkedHashMap<>();
+    response.put("numFound", 1L);
+    response.put("docs", new ArrayList<>(List.of(parent)));
+    NamedList<Object> in = new NamedList<>();
+    in.add("response", response);
+
+    SolrDocument parentDoc =
+        ((SolrDocumentList) 
ResponseCanonicalizer.canonicalize(in).get("response")).get(0);
+    assertTrue(
+        "an object with no nest marker must stay a NamedList",
+        parentDoc.getFieldValue("someStruct") instanceof NamedList);
+  }
+
+  @Test
+  public void testListOfMapsNormalized() {
+    Map<String, Object> a = new LinkedHashMap<>();
+    a.put("x", 1);
+    Map<String, Object> b = new LinkedHashMap<>();
+    b.put("y", 2);
+    NamedList<Object> in = new NamedList<>();
+    in.add("things", new ArrayList<>(Arrays.asList(a, b)));
+
+    NamedList<Object> out = ResponseCanonicalizer.canonicalize(in);
+    List<?> things = (List<?>) out.get("things");
+    assertTrue(things.get(0) instanceof NamedList);
+    assertEquals(1, ((NamedList<?>) things.get(0)).get("x"));
+  }
+
+  @Test
+  public void testMixedNumberTypesPreserved() {
+    // normalizer preserves numeric values as-is (widening happens at the 
getter layer)
+    Map<String, Object> header = new LinkedHashMap<>();
+    header.put("status", 0L); // JSON Long
+    header.put("QTime", 7L);
+    NamedList<Object> in = new NamedList<>();
+    in.add("responseHeader", header);
+
+    NamedList<Object> out = ResponseCanonicalizer.canonicalize(in);
+    NamedList<?> h = (NamedList<?>) out.get("responseHeader");
+    assertEquals(0L, h.get("status"));
+    assertEquals(7L, h.get("QTime"));
+  }
+
+  @Test
+  public void testNotADocListWhenNumFoundMissing() {
+    // a map with "docs" but no numeric numFound is NOT a doc list -> stays a 
NamedList
+    Map<String, Object> notDocs = new LinkedHashMap<>();
+    notDocs.put("docs", new ArrayList<>());
+    NamedList<Object> in = new NamedList<>();
+    in.add("x", notDocs);
+
+    assertTrue(ResponseCanonicalizer.canonicalize(in).get("x") instanceof 
NamedList);
+  }
+
+  /**
+   * A plain {@link NamedList} must not be promoted to a {@link 
SimpleOrderedMap}. The two are
+   * written differently — a JSON writer renders a SimpleOrderedMap as {@code 
{"foo":10}} and a
+   * NamedList as {@code ["foo",10]} — and SimpleOrderedMap also implements 
{@link java.util.Map},
+   * whose contract assumes unique keys that a general NamedList does not 
guarantee. Normalizing
+   * must preserve the concrete type rather than widen it.
+   */
+  public void testPlainNamedListIsNotPromotedToMap() {
+    NamedList<Object> plain = new NamedList<>();
+    plain.add("dup", 1);
+    plain.add("dup", 2);
+
+    NamedList<Object> in = new SimpleOrderedMap<>();
+    in.add("section", plain);
+
+    Object out = ResponseCanonicalizer.canonicalize(in).get("section");
+    assertTrue("must stay a NamedList", out instanceof NamedList);
+    assertFalse(
+        "a plain NamedList must not become a SimpleOrderedMap", out instanceof 
SimpleOrderedMap);
+
+    // and the repeated key survives, which is the reason the distinction 
matters
+    NamedList<?> outList = (NamedList<?>) out;
+    assertEquals(2, outList.size());
+    assertEquals("dup", outList.getName(0));
+    assertEquals("dup", outList.getName(1));
+    assertEquals(1, outList.getVal(0));
+    assertEquals(2, outList.getVal(1));
+  }
+
+  /** A SimpleOrderedMap stays one: it is what the binary parser produces and 
extractors cast to. */
+  public void testSimpleOrderedMapStaysOne() {
+    NamedList<Object> inner = new SimpleOrderedMap<>();
+    inner.add("a", 1);
+
+    NamedList<Object> in = new SimpleOrderedMap<>();
+    in.add("section", inner);
+
+    Object out = ResponseCanonicalizer.canonicalize(in).get("section");
+    assertTrue("must stay a SimpleOrderedMap", out instanceof 
SimpleOrderedMap);
+  }
+
+  /**
+   * JSON conveys nested documents as a {@code _childDocuments_} field holding 
a list of maps; the
+   * binary and XML parsers hand them back as child documents, so this one 
must too.
+   */
+  @Test
+  public void testChildDocumentsAreReconstructed() {
+    Map<String, Object> kid = new LinkedHashMap<>();
+    kid.put("id", "kid1");
+    Map<String, Object> parent = new LinkedHashMap<>();
+    parent.put("id", "parent1");
+    parent.put(CommonParams.CHILDDOC, List.of(kid));
+    Map<String, Object> docList = new LinkedHashMap<>();
+    docList.put("numFound", 1);
+    docList.put("docs", List.of(parent));
+    NamedList<Object> in = new SimpleOrderedMap<>();
+    in.add("response", docList);
+
+    SolrDocumentList out =
+        (SolrDocumentList) 
ResponseCanonicalizer.canonicalize(in).get("response");
+    SolrDocument outParent = out.get(0);
+    assertTrue("child documents must be reconstructed", 
outParent.hasChildDocuments());
+    assertEquals(1, outParent.getChildDocuments().size());
+    assertEquals("kid1", 
outParent.getChildDocuments().get(0).getFieldValue("id"));
+    assertNull(
+        "the raw field must not remain alongside the children",
+        outParent.getFieldValue(CommonParams.CHILDDOC));
+  }
+
+  /** Children nest, so a grandchild must be reconstructed too. */
+  @Test
+  public void testChildDocumentsNest() {
+    Map<String, Object> grandkid = new LinkedHashMap<>();
+    grandkid.put("id", "grandkid1");
+    Map<String, Object> kid = new LinkedHashMap<>();
+    kid.put("id", "kid1");
+    kid.put(CommonParams.CHILDDOC, List.of(grandkid));
+    Map<String, Object> parent = new LinkedHashMap<>();
+    parent.put("id", "parent1");
+    parent.put(CommonParams.CHILDDOC, List.of(kid));
+    Map<String, Object> docList = new LinkedHashMap<>();
+    docList.put("numFound", 1);
+    docList.put("docs", List.of(parent));
+    NamedList<Object> in = new SimpleOrderedMap<>();
+    in.add("response", docList);
+
+    SolrDocumentList out =
+        (SolrDocumentList) 
ResponseCanonicalizer.canonicalize(in).get("response");
+    SolrDocument outKid = out.get(0).getChildDocuments().get(0);
+    assertTrue("grandchildren must be reconstructed", 
outKid.hasChildDocuments());
+    assertEquals("grandkid1", 
outKid.getChildDocuments().get(0).getFieldValue("id"));
+  }
+}
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseParserCanonicalResponseTest.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseParserCanonicalResponseTest.java
new file mode 100644
index 00000000000..746a27b2bcb
--- /dev/null
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseParserCanonicalResponseTest.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.client.solrj.response;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.Map;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.response.json.CanonicalJsonResponseParser;
+import org.apache.solr.client.solrj.response.json.JsonMapResponseParser;
+import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.common.util.NamedList;
+import org.junit.Test;
+
+/**
+ * Pins {@link ResponseParser#processResponse}'s canonical-shape contract: a 
{@link NamedList} tree
+ * with {@link SolrDocumentList} for document sections. {@link 
JsonMapResponseParser} is the one
+ * deliberate exception — {@link CanonicalJsonResponseParser} is the subclass 
that converts.
+ */
+public class ResponseParserCanonicalResponseTest extends SolrTestCase {
+
+  private static final String JSON =
+      """
+      {"responseHeader":{"status":0,"QTime":1},\
+      
"response":{"numFound":1,"start":0,"numFoundExact":true,"docs":[{"id":"1"}]}}""";
+
+  private static InputStream json() {
+    return new ByteArrayInputStream(JSON.getBytes(UTF_8));
+  }
+
+  /** The JSON map parser's own output is raw: Maps where the response classes 
expect NamedLists. */
+  @Test
+  public void testJsonMapParserRawOutputIsNotCanonical() throws Exception {
+    NamedList<Object> raw = new 
JsonMapResponseParser().processResponse(json(), null);
+    assertTrue("raw header should be a Map", raw.get("responseHeader") 
instanceof Map);
+    assertFalse(
+        "raw header should not be a NamedList", raw.get("responseHeader") 
instanceof NamedList);
+    assertFalse(
+        "raw response should not be a SolrDocumentList",
+        raw.get("response") instanceof SolrDocumentList);
+  }
+
+  /** ... and the canonical subclass converts it, without the caller asking. */
+  @Test
+  public void testCanonicalJsonResponseParserConverts() throws Exception {
+    NamedList<Object> out = new 
CanonicalJsonResponseParser().processResponse(json(), null);
+    assertTrue("header must be a NamedList", out.get("responseHeader") 
instanceof NamedList);
+    assertTrue(
+        "response must be a SolrDocumentList", out.get("response") instanceof 
SolrDocumentList);
+    assertEquals(1, ((SolrDocumentList) out.get("response")).getNumFound());
+  }
+
+  /** A parser that is canonical by construction needs no conversion. */
+  @Test
+  public void testXmlResponseParserIsAlreadyCanonical() throws Exception {
+    String xml =
+        """
+        <?xml version="1.0" encoding="UTF-8"?>
+        <response><lst name="responseHeader"><int 
name="status">0</int></lst></response>""";
+    NamedList<Object> out =
+        new XMLResponseParser()
+            .processResponse(new ByteArrayInputStream(xml.getBytes(UTF_8)), 
null);
+    assertTrue("header must be a NamedList", out.get("responseHeader") 
instanceof NamedList);
+  }
+}
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/response/SolrResponseBaseTest.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/SolrResponseBaseTest.java
new file mode 100644
index 00000000000..ff4a16b3ae0
--- /dev/null
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/SolrResponseBaseTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.response;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.response.json.JsonMapResponseParser;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SimpleOrderedMap;
+import org.junit.Test;
+
+/** Tests that {@link SolrResponseBase} getters work across ResponseParsers 
(SOLR-17316). */
+public class SolrResponseBaseTest extends SolrTestCase {
+
+  /** The JSON parser yields a Map header with Long numbers, the case that 
regressed. */
+  @Test
+  public void testStatusAndQTimeWithJsonParser() throws Exception {
+    String json = "{\"responseHeader\":{\"status\":0,\"QTime\":7}}";
+    NamedList<Object> parsed =
+        new JsonMapResponseParser()
+            .processResponse(
+                new 
ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), "UTF-8");
+
+    SolrResponseBase response = new SolrResponseBase();
+    response.setResponse(parsed);
+
+    assertEquals(0, response.getStatus());
+    assertEquals(7, response.getQTime());
+  }
+
+  /** The binary parser yields a NamedList header with Integer numbers (the 
original happy path). */
+  @Test
+  public void testStatusAndQTimeWithBinaryStyleHeader() {
+    NamedList<Object> header = new SimpleOrderedMap<>();
+    header.add("status", 0);
+    header.add("QTime", 7);
+    NamedList<Object> body = new SimpleOrderedMap<>();
+    body.add("responseHeader", header);
+
+    SolrResponseBase response = new SolrResponseBase();
+    response.setResponse(body);
+
+    assertEquals(0, response.getStatus());
+    assertEquals(7, response.getQTime());
+  }
+
+  /** With no responseHeader the getters return 0 rather than throwing. */
+  @Test
+  public void testStatusAndQTimeWithNoHeader() {
+    SolrResponseBase response = new SolrResponseBase();
+    response.setResponse(new SimpleOrderedMap<>());
+
+    assertEquals(0, response.getStatus());
+    assertEquals(0, response.getQTime());
+  }
+}
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/response/TestSuggesterResponse.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/TestSuggesterResponse.java
index 0ed4faf036c..2a660c7e4b9 100644
--- 
a/solr/solrj/src/test/org/apache/solr/client/solrj/response/TestSuggesterResponse.java
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/response/TestSuggesterResponse.java
@@ -26,6 +26,7 @@ import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.request.QueryRequest;
 import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.response.json.CanonicalJsonResponseParser;
 import org.apache.solr.common.SolrInputDocument;
 import org.apache.solr.common.util.EnvUtils;
 import org.apache.solr.util.ExternalPaths;
@@ -134,11 +135,17 @@ public class TestSuggesterResponse extends SolrTestCaseJ4 
{
   }
 
   /*
-   * Randomizes the ResponseParser to test that both javabin and xml responses 
parse correctly.  See SOLR-15070
+   * Randomizes the ResponseParser so that every wt the response classes are 
expected to work with is
+   * exercised: javabin and xml (SOLR-15070), and the JSON map parser, whose 
raw Maps are converted to
+   * the canonical shape by the parser itself (SOLR-17316).
    */
   private SolrClient createSuggestSolrClient() {
     final ResponseParser randomParser =
-        random().nextBoolean() ? new JavaBinResponseParser() : new 
XMLResponseParser();
+        switch (random().nextInt(3)) {
+          case 0 -> new JavaBinResponseParser();
+          case 1 -> new XMLResponseParser();
+          default -> new CanonicalJsonResponseParser();
+        };
     return 
solrTestRule.newSolrClientBuilder().withResponseParser(randomParser).build();
   }
 }

Reply via email to