Skip to content
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* This allows the application to keep all existing SolrJ response handling
* unchanged while moving off the JavaBin ({@code wt=javabin}) wire format.
*
* <p>
* <strong>Structural conversions:</strong>
* <ul>
* <li>JSON objects → {@link NamedList} (preserving key order)</li>
* <li>JSON objects containing {@code numFound} + {@code docs} →
* {@link SolrDocumentList}</li>
* <li>JSON arrays with alternating {@code [String, non-String, ...]} pairs →
* {@link NamedList} (Solr's {@code json.nl=flat} facet encoding)</li>
* <li>All other JSON arrays → {@link List}</li>
* <li>JSON integers → {@link Integer} or {@link Long} (by value size)</li>
* <li>JSON decimals → {@link Double}</li>
* <li>JSON booleans → {@link Boolean}</li>
* <li>JSON strings → {@link String}</li>
* </ul>
*
* <p>
* <strong>Flat NamedList detection:</strong> 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<Object> 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<Object> 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<Object> toNamedList(JsonNode objectNode) {
SimpleOrderedMap<Object> 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<Object> 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<Object> flatArrayToNamedList(JsonNode arrayNode) {
SimpleOrderedMap<Object> 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<Object> 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;
}
}
13 changes: 10 additions & 3 deletions src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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();
}
}
105 changes: 0 additions & 105 deletions src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
}
}
Loading