diff --git a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java new file mode 100644 index 0000000..b57daf8 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java @@ -0,0 +1,212 @@ +/* + * 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.mcp.server.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; +import org.apache.solr.client.solrj.ResponseParser; +import org.apache.solr.common.SolrDocument; +import org.apache.solr.common.SolrDocumentList; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.SimpleOrderedMap; +import org.springframework.http.MediaType; + +/** + * SolrJ {@link ResponseParser} that requests JSON wire format ({@code wt=json}) + * and converts the Solr JSON response into the {@link NamedList} object tree + * that SolrJ's typed response classes + * ({@link org.apache.solr.client.solrj.response.QueryResponse}, + * {@link org.apache.solr.client.solrj.response.LukeResponse}, etc.) expect + * internally. + * + *

+ * This allows the application to keep all existing SolrJ response handling + * unchanged while moving off the JavaBin ({@code wt=javabin}) wire format. + * + *

+ * Structural conversions: + *

+ * + *

+ * Flat NamedList detection: Solr serializes facet counts as + * flat arrays using {@code json.nl=flat} (the default). An array is treated as + * a flat NamedList when every even-indexed element is a {@link String} and + * every odd-indexed element is a non-{@link String} value. This reliably + * distinguishes {@code ["term", 5, "term2", 3]} (facet NamedList) from + * {@code ["col1", "col2"]} (plain string list). + */ +class JsonResponseParser extends ResponseParser { + + private final ObjectMapper mapper; + + JsonResponseParser(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public String getWriterType() { + return "json"; + } + + @Override + public String getContentType() { + return MediaType.APPLICATION_JSON_VALUE; + } + + @Override + public NamedList processResponse(InputStream body, String encoding) { + try { + return toNamedList(mapper.readTree(body)); + } catch (IOException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON response", e); + } + } + + @Override + public NamedList processResponse(Reader reader) { + try { + return toNamedList(mapper.readTree(reader)); + } catch (IOException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON response", e); + } + } + + private SimpleOrderedMap toNamedList(JsonNode objectNode) { + SimpleOrderedMap result = new SimpleOrderedMap<>(); + objectNode.fields().forEachRemaining(entry -> result.add(entry.getKey(), convertValue(entry.getValue()))); + return result; + } + + private Object convertValue(JsonNode node) { + if (node.isNull()) + return null; + if (node.isBoolean()) + return node.booleanValue(); + if (node.isTextual()) + return node.textValue(); + if (node.isInt()) + return node.intValue(); + if (node.isLong()) + return node.longValue(); + if (node.isDouble() || node.isFloat()) + return node.floatValue(); + if (node.isObject()) + return convertObject(node); + if (node.isArray()) + return convertArray(node); + return node.asText(); + } + + private Object convertObject(JsonNode node) { + // Detect a Solr query result set by the presence of numFound + docs + if (node.has("numFound") && node.has("docs")) { + return toSolrDocumentList(node); + } + return toNamedList(node); + } + + private Object convertArray(JsonNode arrayNode) { + // Detect Solr's flat NamedList encoding: [String, non-String, String, + // non-String, ...] + // Used for facet counts (json.nl=flat default). Distinguished from plain string + // arrays + // by requiring odd-indexed elements to be non-string values. + if (isFlatNamedList(arrayNode)) { + return flatArrayToNamedList(arrayNode); + } + List list = new ArrayList<>(arrayNode.size()); + arrayNode.forEach(element -> list.add(convertValue(element))); + return list; + } + + /** + * Returns true when the array has even length, every even-indexed element is a + * string (the key), and every odd-indexed element is NOT a string (the value). + * This heuristic correctly identifies Solr facet data like + * {@code ["fantasy", 10, "scifi", 5]} while rejecting plain string arrays like + * {@code ["col1", "col2"]}. + */ + private boolean isFlatNamedList(JsonNode arrayNode) { + int size = arrayNode.size(); + if (size == 0 || size % 2 != 0) + return false; + for (int i = 0; i < size; i += 2) { + if (!arrayNode.get(i).isTextual()) + return false; // key must be string + if (arrayNode.get(i + 1).isTextual()) + return false; // value must not be string + } + return true; + } + + private SimpleOrderedMap flatArrayToNamedList(JsonNode arrayNode) { + SimpleOrderedMap result = new SimpleOrderedMap<>(); + for (int i = 0; i < arrayNode.size(); i += 2) { + result.add(arrayNode.get(i).textValue(), convertValue(arrayNode.get(i + 1))); + } + return result; + } + + private SolrDocumentList toSolrDocumentList(JsonNode node) { + SolrDocumentList list = new SolrDocumentList(); + list.setNumFound(node.get("numFound").longValue()); + list.setStart(node.path("start").longValue()); + JsonNode numFoundExact = node.get("numFoundExact"); + if (numFoundExact != null && !numFoundExact.isNull()) { + list.setNumFoundExact(numFoundExact.booleanValue()); + } + JsonNode maxScore = node.get("maxScore"); + if (maxScore != null && !maxScore.isNull()) { + list.setMaxScore(maxScore.floatValue()); + } + node.get("docs").forEach(doc -> list.add(toSolrDocument(doc))); + return list; + } + + private SolrDocument toSolrDocument(JsonNode node) { + SolrDocument doc = new SolrDocument(); + node.fields().forEachRemaining(entry -> { + JsonNode val = entry.getValue(); + if (val.isArray()) { + // Multi-valued field — always a plain list, never a flat NamedList + List values = new ArrayList<>(val.size()); + val.forEach(v -> values.add(convertValue(v))); + doc.setField(entry.getKey(), values); + } else { + doc.setField(entry.getKey(), convertValue(val)); + } + }); + return doc; + } +} diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java index 7359ccf..ed2cf88 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java @@ -16,6 +16,7 @@ */ package org.apache.solr.mcp.server.config; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.concurrent.TimeUnit; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.Http2SolrClient; @@ -168,7 +169,12 @@ public class SolrConfig { * @see SolrConfigurationProperties#url() */ @Bean - SolrClient solrClient(SolrConfigurationProperties properties) { + JsonResponseParser jsonResponseParser(ObjectMapper objectMapper) { + return new JsonResponseParser(objectMapper); + } + + @Bean + SolrClient solrClient(SolrConfigurationProperties properties, JsonResponseParser jsonResponseParser) { String url = properties.url(); // Ensure URL is properly formatted for Solr @@ -186,8 +192,9 @@ SolrClient solrClient(SolrConfigurationProperties properties) { } } - // Use with explicit base URL + // Use with explicit base URL; JSON wire format replaces the JavaBin default return new Http2SolrClient.Builder(url).withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).build(); + .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).withResponseParser(jsonResponseParser) + .build(); } } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index 342ddd0..ce109c6 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -22,8 +22,6 @@ import org.apache.solr.client.solrj.impl.Http2SolrClient; import org.apache.solr.mcp.server.TestcontainersConfiguration; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; @@ -67,107 +65,4 @@ void testSolrConfigurationProperties() { properties.url()); } - @ParameterizedTest - @CsvSource({"http://localhost:8983, http://localhost:8983/solr", - "http://localhost:8983/, http://localhost:8983/solr", - "http://localhost:8983/solr, http://localhost:8983/solr", - "http://localhost:8983/solr/, http://localhost:8983/solr", - "http://localhost:8983/custom/solr/, http://localhost:8983/custom/solr"}) - void testUrlNormalization(String inputUrl, String expectedUrl) { - // Create a test properties object - SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl); - - // Create SolrConfig instance - SolrConfig solrConfig = new SolrConfig(); - - // Test URL normalization - SolrClient client = solrConfig.solrClient(testProperties); - assertNotNull(client); - - var httpClient = assertInstanceOf(Http2SolrClient.class, client); - assertEquals(expectedUrl, httpClient.getBaseURL()); - - // Clean up - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithoutTrailingSlash() { - // Test URL without trailing slash branch - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add trailing slash and solr path - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithTrailingSlashButNoSolrPath() { - // Test URL with trailing slash but no solr path branch - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add solr path to existing trailing slash - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithSolrPathButNoTrailingSlash() { - // Test URL with solr path but no trailing slash - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add trailing slash - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlAlreadyProperlyFormatted() { - // Test URL that's already properly formatted - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr/"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should remain unchanged - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java new file mode 100644 index 0000000..bca9219 --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java @@ -0,0 +1,52 @@ +/* + * 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.mcp.server.config; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.impl.Http2SolrClient; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; + +@JsonTest +class SolrConfigUrlNormalizationTest { + + @Autowired + private ObjectMapper objectMapper; + + @ParameterizedTest + @CsvSource({"http://localhost:8983, http://localhost:8983/solr", + "http://localhost:8983/, http://localhost:8983/solr", + "http://localhost:8983/solr, http://localhost:8983/solr", + "http://localhost:8983/solr/, http://localhost:8983/solr", + "http://localhost:8983/custom/solr/, http://localhost:8983/custom/solr"}) + void testUrlNormalization(String inputUrl, String expectedUrl) throws Exception { + SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl); + SolrConfig solrConfig = new SolrConfig(); + + try (SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper))) { + assertNotNull(client); + + var httpClient = assertInstanceOf(Http2SolrClient.class, client); + assertEquals(expectedUrl, httpClient.getBaseURL()); + } + } +}