diff --git a/changelog/unreleased/SOLR-18555.yml b/changelog/unreleased/SOLR-18555.yml new file mode 100644 index 000000000000..9a3210f1e34b --- /dev/null +++ b/changelog/unreleased/SOLR-18555.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Add a request handler for highlighting supplied text without searching the index. +type: added +authors: + - name: Nick Shanin +links: + - name: SOLR-18555 + url: https://issues.apache.org/jira/browse/SOLR-18555 diff --git a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java new file mode 100644 index 000000000000..9d21d9745bba --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java @@ -0,0 +1,310 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.handler; + +import java.io.IOException; +import java.io.Reader; +import java.text.BreakIterator; +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.uhighlight.DefaultPassageFormatter; +import org.apache.lucene.search.uhighlight.PassageFormatter; +import org.apache.lucene.search.uhighlight.UnifiedHighlighter; +import org.apache.lucene.search.uhighlight.WholeBreakIterator; +import org.apache.solr.api.Api; +import org.apache.solr.api.ApiBag; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.HighlightParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.common.util.ContentStream; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.SimpleOrderedMap; +import org.apache.solr.common.util.StrUtils; +import org.apache.solr.highlight.SolrHighlighter; +import org.apache.solr.highlight.UnifiedSolrHighlighter; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.search.QParser; +import org.apache.solr.search.QParserPlugin; +import org.apache.solr.search.QueryParsing; +import org.apache.solr.search.SolrQueryParser; +import org.apache.solr.search.SyntaxError; +import org.apache.solr.security.AuthorizationContext; + +/** Highlights supplied plain text with a query. */ +public class HighlightRequestHandler extends RequestHandlerBase { + + private static final String TEXT_RESPONSE_KEY = "text"; + private static final String STREAM_BODY_PARAM = "stream.body"; + static final int MAX_ALLOWED_CHARS = 1_048_576; + + @Override + public String getDescription() { + return "Highlights supplied text with a query."; + } + + @Override + public Boolean registerV2() { + return true; + } + + @Override + public Collection getApis() { + return Set.of( + new ApiBag.ReqHandlerToApi( + this, + () -> { + var spec = ApiBag.HANDLER_NAME_SPEC_PROVIDER.getSpec(); + spec.put("methods", List.of("GET", "POST")); + return spec; + })); + } + + @Override + public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { + SolrParams params = req.getParams(); + int maxChars = params.getInt(HighlightParams.MAX_CHARS, SolrHighlighter.DEFAULT_MAX_CHARS); + if (maxChars < 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + HighlightParams.MAX_CHARS + " must be a positive integer"); + } + if (maxChars > MAX_ALLOWED_CHARS) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + HighlightParams.MAX_CHARS + " must not exceed " + MAX_ALLOWED_CHARS); + } + + String text = readText(req, maxChars); + String queryString = params.get(HighlightParams.Q, params.get(CommonParams.Q)); + if (queryString == null || queryString.isBlank()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "A query is required using '" + CommonParams.Q + "' or '" + HighlightParams.Q + "'"); + } + + String parserType = params.get(HighlightParams.QPARSER, params.get(QueryParsing.DEFTYPE)); + if (parserType != null && !QParserPlugin.DEFAULT_QTYPE.equals(parserType)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "HighlightRequestHandler supports only the '" + + QParserPlugin.DEFAULT_QTYPE + + "' query parser"); + } + if (queryString.stripLeading().startsWith("{!")) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Local-parameter query parsers are not supported by HighlightRequestHandler"); + } + QParser parser = createQueryParser(queryString, req); + Query query = parser.getHighlightQuery(); + if (query == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "The query did not produce a highlight query"); + } + + rejectWildcardFields(params.getParams(HighlightParams.FIELDS)); + rejectWildcardFields(parser.getDefaultHighlightFields()); + String[] fields = + new UnifiedSolrHighlighter() + .getHighlightFields(query, req, parser.getDefaultHighlightFields()); + rejectWildcardFields(fields); + if (fields.length == 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "A field is required using '" + + HighlightParams.FIELDS + + "' or '" + + CommonParams.DF + + "'"); + } + + UnifiedHighlighter highlighter = createHighlighter(req, maxChars); + NamedList fieldsToSnippets = new SimpleOrderedMap<>(); + for (String field : fields) { + int maxPassages = params.getFieldInt(field, HighlightParams.SNIPPETS, 1); + Object highlightedText = + highlighter.highlightWithoutSearcher(field, query, text, maxPassages); + if (highlightedText != null) { + fieldsToSnippets.add(field, new String[] {(String) highlightedText}); + } + } + + NamedList documentHighlights = new SimpleOrderedMap<>(); + documentHighlights.add(TEXT_RESPONSE_KEY, fieldsToSnippets); + rsp.add("highlighting", documentHighlights); + } + + @Override + public Name getPermissionName(AuthorizationContext request) { + return Name.READ_PERM; + } + + private static String readText(SolrQueryRequest req, int maxChars) throws IOException { + Iterable contentStreams = req.getContentStreams(); + if (contentStreams != null) { + var iterator = contentStreams.iterator(); + if (iterator.hasNext()) { + ContentStream contentStream = iterator.next(); + if (iterator.hasNext()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "HighlightRequestHandler accepts one text/plain content stream"); + } + String contentType = contentStream.getContentType(); + String mediaType = contentType == null ? null : contentType.split(";", 2)[0].trim(); + if (!"text/plain".equalsIgnoreCase(mediaType)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "HighlightRequestHandler requires a text/plain content stream"); + } + try (Reader reader = contentStream.getReader()) { + return readUpTo(reader, maxChars); + } + } + } + + String streamBody = req.getParams().get(STREAM_BODY_PARAM); + if (streamBody != null) { + return streamBody.length() <= maxChars ? streamBody : streamBody.substring(0, maxChars); + } + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "HighlightRequestHandler requires text/plain content or the '" + + STREAM_BODY_PARAM + + "' parameter"); + } + + private static String readUpTo(Reader reader, int maxChars) throws IOException { + char[] buffer = new char[Math.min(maxChars, 8192)]; + StringBuilder text = new StringBuilder(buffer.length); + int remaining = maxChars; + int read; + while (remaining > 0 + && (read = reader.read(buffer, 0, Math.min(remaining, buffer.length))) != -1) { + text.append(buffer, 0, read); + remaining -= read; + } + return text.toString(); + } + + private static void rejectWildcardFields(String[] fields) { + if (fields == null) return; + for (String field : fields) { + if (field != null && field.contains("*")) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Wildcard fields are not supported because highlighting does not access the index"); + } + } + } + + private static UnifiedHighlighter createHighlighter(SolrQueryRequest req, int maxChars) { + SolrParams params = req.getParams(); + UnifiedHighlighter.Builder builder = + UnifiedHighlighter.builderWithoutSearcher(req.getSchema().getIndexAnalyzer()) + .withMaxLength(maxChars) + .withHandleMultiTermQuery(true) + .withHighlightPhrasesStrictly(true) + .withPassageRelevancyOverSpeed(true) + .withWeightMatches(true); + + return new UnifiedHighlighter(builder) { + @Override + protected BreakIterator getBreakIterator(String field) { + return new WholeBreakIterator(); + } + + @Override + protected int getMaxNoHighlightPassages(String field) { + return params.getFieldBool(field, HighlightParams.DEFAULT_SUMMARY, false) ? -1 : 0; + } + + @Override + protected PassageFormatter getFormatter(String field) { + String preTag = + params.getFieldParam( + field, + HighlightParams.TAG_PRE, + params.getFieldParam(field, HighlightParams.SIMPLE_PRE, "")); + String postTag = + params.getFieldParam( + field, + HighlightParams.TAG_POST, + params.getFieldParam(field, HighlightParams.SIMPLE_POST, "")); + String encoder = params.getFieldParam(field, HighlightParams.ENCODER, "simple"); + return new DefaultPassageFormatter(preTag, postTag, "", "html".equals(encoder)); + } + + @Override + protected Set getFlags(String field) { + Set flags = EnumSet.noneOf(HighlightFlag.class); + flags.addAll(super.getFlags(field)); + if (!params.getFieldBool(field, HighlightParams.HIGHLIGHT_MULTI_TERM, true)) { + flags.remove(HighlightFlag.MULTI_TERM_QUERY); + } + if (!params.getFieldBool(field, HighlightParams.USE_PHRASE_HIGHLIGHTER, true)) { + flags.remove(HighlightFlag.PHRASES); + } + if (!flags.contains(HighlightFlag.MULTI_TERM_QUERY) + || !flags.contains(HighlightFlag.PHRASES)) { + flags.remove(HighlightFlag.WEIGHT_MATCHES); + } + return flags; + } + + @Override + protected Predicate getFieldMatcher(String field) { + return params.getFieldBool(field, HighlightParams.FIELD_MATCH, false) + ? field::equals + : ignored -> true; + } + }; + } + + private static QParser createQueryParser(String queryString, SolrQueryRequest req) { + // Do not use QParser.getParser here: configured plugins and local parameters can dispatch to + // parsers that use the index. Keep this parser limited to the built-in Lucene syntax. + return new QParser(queryString, null, req.getParams(), req) { + private SolrQueryParser parser; + + @Override + public Query parse() throws SyntaxError { + if (qstr == null || qstr.isEmpty()) return null; + + parser = new SolrQueryParser(this, getParam(CommonParams.DF)); + parser.setDefaultOperator(QueryParsing.parseOP(getParam(QueryParsing.OP))); + parser.setSplitOnWhitespace( + StrUtils.parseBool( + getParam(QueryParsing.SPLIT_ON_WHITESPACE), + SolrQueryParser.DEFAULT_SPLIT_ON_WHITESPACE)); + parser.setAllowSubQueryParsing(false); + return parser.parse(qstr); + } + + @Override + public String[] getDefaultHighlightFields() { + return parser == null ? new String[] {} : new String[] {parser.getDefaultField()}; + } + }; + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerCloudHttpTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerCloudHttpTest.java new file mode 100644 index 000000000000..cd188c5ea97d --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerCloudHttpTest.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.handler; + +import static org.apache.solr.core.CoreContainer.ALLOW_PATHS_SYSPROP; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.apache.solr.SolrTestCaseJ4.SuppressSSL; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.util.EnvUtils; +import org.apache.solr.util.ExternalPaths; +import org.eclipse.jetty.client.ContentResponse; +import org.junit.BeforeClass; +import org.junit.Test; + +@SuppressSSL +public class HighlightRequestHandlerCloudHttpTest extends SolrCloudTestCase { + + private static final String CORE_NAME = "highlight"; + private static final String TEXT = "Solr highlights the text."; + + @BeforeClass + public static void startCluster() throws Exception { + EnvUtils.setProperty( + ALLOW_PATHS_SYSPROP, ExternalPaths.SERVER_HOME.toAbsolutePath().toString()); + configureCluster(1).addConfig("default", ExternalPaths.DEFAULT_CONFIGSET).configure(); + CollectionAdminRequest.createCollection(CORE_NAME, "default", 1, 1) + .process(cluster.getSolrClient()); + cluster.waitForActiveCollection(CORE_NAME, 1, 1); + } + + @Test + public void testGetStreamBodyOnV2CollectionRoute() throws Exception { + String params = + "?q=_text_%3Asolr&hl.fl=_text_&wt=json&stream.body=" + + URLEncoder.encode(TEXT, StandardCharsets.UTF_8); + String v2CollectionUrl = + cluster.getJettySolrRunner(0).getBaseURLV2() + "/c/" + CORE_NAME + "/highlight"; + + assertHighlightResponse( + cluster + .getJettySolrRunner(0) + .getSolrClient() + .getHttpClient() + .GET(v2CollectionUrl + params)); + } + + private static void assertHighlightResponse(ContentResponse response) { + String body = response.getContentAsString(); + assertEquals(body, 200, response.getStatus()); + assertTrue(body, body.contains("\"highlighting\"")); + assertTrue(body, body.contains("\"_text_\"")); + assertTrue(body, body.contains("Solr")); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java new file mode 100644 index 000000000000..7b521b592b9c --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.handler; + +import static org.apache.solr.core.CoreContainer.ALLOW_PATHS_SYSPROP; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.SolrTestCaseJ4.SuppressSSL; +import org.apache.solr.common.util.EnvUtils; +import org.apache.solr.util.ExternalPaths; +import org.apache.solr.util.SolrJettyTestRule; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.StringRequestContent; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +@SuppressSSL +public class HighlightRequestHandlerHttpTest extends SolrTestCaseJ4 { + + private static final String CORE_NAME = "highlight"; + private static final String TEXT = "Solr highlights the text."; + + @ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void startSolr() throws Exception { + EnvUtils.setProperty( + ALLOW_PATHS_SYSPROP, ExternalPaths.SERVER_HOME.toAbsolutePath().toString()); + solrTestRule.startSolr(); + solrTestRule.newCollection(CORE_NAME).withConfigSet(ExternalPaths.DEFAULT_CONFIGSET).create(); + } + + @Test + public void testPostAcceptsPlainTextBody() throws Exception { + String url = + solrTestRule.getBaseUrl() + + "/" + + CORE_NAME + + "/highlight?q=_text_%3Asolr&hl.fl=_text_&wt=json"; + + assertHighlightResponse(sendBodyRequest("POST", url)); + } + + @Test + public void testQueryMethodCannotReachUpdateHandler() throws Exception { + String url = solrTestRule.getBaseUrl() + "/" + CORE_NAME + "/update?wt=json"; + String update = "query-method-must-not-update"; + + ContentResponse response = sendBodyRequest("QUERY", url, "application/xml", update); + + assertTrue(response.getContentAsString(), response.getStatus() != 200); + } + + @Test + public void testQueryMethodIsNotRegisteredForV2Handler() throws Exception { + String v2Url = solrTestRule.getJetty().getBaseURLV2() + "/cores/" + CORE_NAME + "/highlight"; + String url = v2Url + "?q=_text_%3Asolr&hl.fl=_text_&wt=json"; + + ContentResponse response = sendBodyRequest("QUERY", url); + + assertTrue(response.getContentAsString(), response.getStatus() != 200); + } + + @Test + public void testGetStreamBodyAndV2Routes() throws Exception { + String encodedText = URLEncoder.encode(TEXT, StandardCharsets.UTF_8); + String params = "?q=_text_%3Asolr&hl.fl=_text_&wt=json&stream.body=" + encodedText; + String v2BaseUrl = solrTestRule.getJetty().getBaseURLV2().toString(); + String v2CoreUrl = v2BaseUrl + "/cores/" + CORE_NAME + "/highlight"; + + assertHighlightResponse( + solrTestRule + .getJetty() + .getSolrClient() + .getHttpClient() + .GET(solrTestRule.getBaseUrl() + "/" + CORE_NAME + "/highlight" + params)); + + assertHighlightResponse( + solrTestRule.getJetty().getSolrClient().getHttpClient().GET(v2CoreUrl + params)); + assertHighlightResponse( + sendBodyRequest("POST", v2CoreUrl + "?q=_text_%3Asolr&hl.fl=_text_&wt=json")); + } + + private static ContentResponse sendBodyRequest(String method, String url) throws Exception { + return sendBodyRequest(method, url, "text/plain; charset=UTF-8", TEXT); + } + + private static ContentResponse sendBodyRequest( + String method, String url, String contentType, String body) throws Exception { + return solrTestRule + .getJetty() + .getSolrClient() + .getHttpClient() + .newRequest(url) + .method(method) + .body(new StringRequestContent(contentType, body, StandardCharsets.UTF_8)) + .send(); + } + + private static void assertHighlightResponse(ContentResponse response) { + String body = response.getContentAsString(); + assertEquals(body, 200, response.getStatus()); + assertTrue(body, body.contains("\"highlighting\"")); + assertTrue(body, body.contains("\"_text_\"")); + assertTrue(body, body.contains("Solr")); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java new file mode 100644 index 000000000000..6974eab16598 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java @@ -0,0 +1,405 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.handler; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.List; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.HighlightParams; +import org.apache.solr.common.params.ModifiableSolrParams; +import org.apache.solr.common.util.ContentStream; +import org.apache.solr.common.util.ContentStreamBase; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.request.SolrQueryRequestBase; +import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.search.SolrIndexSearcher; +import org.apache.solr.security.PermissionNameProvider.Name; +import org.junit.BeforeClass; +import org.junit.Test; + +public class HighlightRequestHandlerTest extends SolrTestCaseJ4 { + + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema.xml"); + } + + @Test + public void testTextPlainResponseShapeWithoutIndexedDocuments() throws Exception { + ModifiableSolrParams params = + paramsSet(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text"); + SolrQueryResponse response = + handle( + params, + List.of(new ContentStreamBase.StringStream("Solr highlights the text.", "text/plain"))); + + assertArrayEquals( + new String[] {"Solr highlights the text."}, snippets(response, "text")); + } + + @Test + public void testGetStreamBodyAndHighlightQueryParameter() throws Exception { + ModifiableSolrParams params = + paramsSet( + HighlightParams.Q, + "text:solr", + HighlightParams.FIELDS, + "text", + "stream.body", + "Solr highlights the text."); + + SolrQueryResponse response = handle(params, null); + + assertArrayEquals( + new String[] {"Solr highlights the text."}, snippets(response, "text")); + } + + @Test + public void testDefaultFieldFallback() throws Exception { + ModifiableSolrParams params = paramsSet(CommonParams.Q, "solr", CommonParams.DF, "text"); + SolrQueryResponse response = + handle( + params, + List.of(new ContentStreamBase.StringStream("Solr highlights the text.", "text/plain"))); + + assertArrayEquals( + new String[] {"Solr highlights the text."}, snippets(response, "text")); + } + + @Test + public void testHtmlEncoderEscapesSuppliedText() throws Exception { + ModifiableSolrParams params = + paramsSet( + CommonParams.Q, + "text:solr", + HighlightParams.FIELDS, + "text", + HighlightParams.ENCODER, + "html"); + + SolrQueryResponse response = + handle( + params, + List.of( + new ContentStreamBase.StringStream( + "Solr", "text/plain; charset=UTF-8"))); + + assertArrayEquals( + new String[] {"<strong>Solr</strong>"}, + snippets(response, "text")); + } + + @Test + public void testPhraseAndMultiTermQueries() throws Exception { + List content = + List.of(new ContentStreamBase.StringStream("Solr highlights the text.", "text/plain")); + ModifiableSolrParams phraseParams = + paramsSet(CommonParams.Q, "text:\"solr highlights\"", HighlightParams.FIELDS, "text"); + SolrQueryResponse phraseResponse = handle(phraseParams, content); + assertArrayEquals( + new String[] {"Solr highlights the text."}, snippets(phraseResponse, "text")); + + ModifiableSolrParams nonPhraseParams = + paramsSet( + CommonParams.Q, + "text:\"solr highlights\"", + HighlightParams.FIELDS, + "text", + HighlightParams.USE_PHRASE_HIGHLIGHTER, + "false"); + SolrQueryResponse nonPhraseResponse = handle(nonPhraseParams, content); + assertArrayEquals( + new String[] {"Solr highlights the text."}, + snippets(nonPhraseResponse, "text")); + + ModifiableSolrParams prefixParams = + paramsSet(CommonParams.Q, "text:sol*", HighlightParams.FIELDS, "text"); + SolrQueryResponse prefixResponse = handle(prefixParams, content); + assertArrayEquals( + new String[] {"Solr highlights the text."}, snippets(prefixResponse, "text")); + } + + @Test + public void testDefaultSummaryReturnsWholeInputWhenThereIsNoMatch() throws Exception { + ModifiableSolrParams params = + paramsSet( + CommonParams.Q, + "text:missing", + HighlightParams.FIELDS, + "text", + HighlightParams.DEFAULT_SUMMARY, + "true"); + SolrQueryResponse response = + handle( + params, + List.of(new ContentStreamBase.StringStream("Solr highlights the text.", "text/plain"))); + + assertArrayEquals(new String[] {"Solr highlights the text."}, snippets(response, "text")); + } + + @Test + public void testLengthLimitTruncatesAndRejectsOversizedLimit() throws Exception { + ModifiableSolrParams params = + paramsSet( + CommonParams.Q, + "text:solr", + HighlightParams.FIELDS, + "text", + HighlightParams.MAX_CHARS, + "4"); + CountingReader countingReader = new CountingReader("Solr highlights"); + ContentStream contentStream = + new ContentStreamBase.StringStream("Solr highlights", "text/plain") { + @Override + public Reader getReader() { + return countingReader; + } + }; + SolrQueryResponse response = handle(params, List.of(contentStream)); + assertArrayEquals(new String[] {"Solr"}, snippets(response, "text")); + assertEquals(4, countingReader.getCharsRead()); + + params.set(HighlightParams.MAX_CHARS, "1048577"); + SolrException exception = + expectThrows( + SolrException.class, + () -> + handle(params, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + assertTrue(exception.getMessage().contains("must not exceed")); + } + + @Test + public void testRejectsInvalidInputs() throws Exception { + ModifiableSolrParams params = + paramsSet(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text"); + SolrException exception = + expectThrows( + SolrException.class, + () -> + handle( + params, + List.of(new ContentStreamBase.StringStream("Solr", "application/json")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + + exception = + expectThrows( + SolrException.class, + () -> + handle( + params, + List.of( + new ContentStreamBase.StringStream("one", "text/plain"), + new ContentStreamBase.StringStream("two", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + + exception = expectThrows(SolrException.class, () -> handle(params, null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + + ModifiableSolrParams noQuery = paramsSet(HighlightParams.FIELDS, "text"); + exception = + expectThrows( + SolrException.class, + () -> + handle(noQuery, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + + ModifiableSolrParams noField = paramsSet(CommonParams.Q, "text:solr"); + exception = + expectThrows( + SolrException.class, + () -> + handle(noField, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + + ModifiableSolrParams wildcardField = + paramsSet(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text*"); + exception = + expectThrows( + SolrException.class, + () -> + handle( + wildcardField, + List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + + ModifiableSolrParams invalidLimit = + paramsSet( + CommonParams.Q, + "text:solr", + HighlightParams.FIELDS, + "text", + HighlightParams.MAX_CHARS, + "0"); + exception = + expectThrows( + SolrException.class, + () -> + handle( + invalidLimit, + List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + } + + @Test + public void testRejectsParserOverridesAndParserSubqueries() throws Exception { + ModifiableSolrParams parserOverride = + paramsSet( + CommonParams.Q, + "text:solr", + HighlightParams.FIELDS, + "text", + HighlightParams.QPARSER, + "mlt"); + SolrException exception = + expectThrows( + SolrException.class, + () -> + handle( + parserOverride, + List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + assertTrue(exception.getMessage().contains("supports only")); + + ModifiableSolrParams defTypeOverride = + paramsSet(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text", "defType", "mlt"); + exception = + expectThrows( + SolrException.class, + () -> + handle( + defTypeOverride, + List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + assertTrue(exception.getMessage().contains("supports only")); + + ModifiableSolrParams localParamsQuery = + paramsSet(CommonParams.Q, "{!mlt qf=text}solr", HighlightParams.FIELDS, "text"); + exception = + expectThrows( + SolrException.class, + () -> + handle( + localParamsQuery, + List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); + assertTrue(exception.getMessage().contains("Local-parameter")); + + ModifiableSolrParams subquery = + paramsSet( + CommonParams.Q, + "text:solr OR _query_:\"{!mlt qf=text}solr\"", + HighlightParams.FIELDS, + "text"); + SolrException subqueryException = + expectThrows( + SolrException.class, + () -> + handle( + subquery, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, subqueryException.code()); + } + + @Test + public void testRegistrationAndPermission() { + RequestHandlerBase handler = newHandler(); + assertTrue(handler.registerV2()); + assertEquals(Name.READ_PERM, handler.getPermissionName(null)); + } + + private static SolrQueryResponse handle( + ModifiableSolrParams params, List contentStreams) throws Exception { + RequestHandlerBase handler = newHandler(); + try (SolrQueryRequestBase request = new NoSearcherRequest(params)) { + if (contentStreams != null) { + request.setContentStreams(contentStreams); + } + SolrQueryResponse response = new SolrQueryResponse(); + handler.handleRequestBody(request, response); + return response; + } + } + + private static RequestHandlerBase newHandler() { + try { + return Class.forName("org.apache.solr.handler.HighlightRequestHandler") + .asSubclass(RequestHandlerBase.class) + .getConstructor() + .newInstance(); + } catch (ReflectiveOperationException e) { + throw new LinkageError("The text highlight request handler must be available", e); + } + } + + private static String[] snippets(SolrQueryResponse response, String field) { + @SuppressWarnings("unchecked") + NamedList highlighting = (NamedList) response.getValues().get("highlighting"); + @SuppressWarnings("unchecked") + NamedList document = (NamedList) highlighting.get("text"); + return (String[]) document.get(field); + } + + private static ModifiableSolrParams paramsSet(String... nameValues) { + ModifiableSolrParams params = new ModifiableSolrParams(); + for (int i = 0; i < nameValues.length; i += 2) { + params.set(nameValues[i], nameValues[i + 1]); + } + return params; + } + + private static class NoSearcherRequest extends SolrQueryRequestBase { + NoSearcherRequest(ModifiableSolrParams params) { + super(h.getCore(), params); + } + + @Override + public SolrIndexSearcher getSearcher() { + throw new AssertionError("Text highlighting must not acquire an index searcher"); + } + } + + private static class CountingReader extends Reader { + private final StringReader delegate; + private int charsRead; + + CountingReader(String text) { + delegate = new StringReader(text); + } + + @Override + public int read(char[] buffer, int offset, int length) throws IOException { + int read = delegate.read(buffer, offset, length); + if (read > 0) { + charsRead += read; + } + return read; + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + int getCharsRead() { + return charsRead; + } + } +} diff --git a/solr/server/solr/configsets/_default/conf/solrconfig.xml b/solr/server/solr/configsets/_default/conf/solrconfig.xml index dbb1924c44b8..9a8edd1797ef 100644 --- a/solr/server/solr/configsets/_default/conf/solrconfig.xml +++ b/solr/server/solr/configsets/_default/conf/solrconfig.xml @@ -608,8 +608,11 @@ + + + - + _text_ diff --git a/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc index 5b51f940050e..05b7118f3c4d 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc @@ -25,9 +25,48 @@ Highlighting is extremely configurable, perhaps more than any other part of Solr There are many parameters each for fragment sizing, formatting, ordering, backup/alternate behavior, and more options that are hard to categorize. Nonetheless, highlighting is very simple to use. +== Highlighting supplied text + +The `/highlight` request handler highlights supplied text without searching +the index. Send a `text/plain` body with POST; for GET, provide the text with +`stream.body`. A query is required in `q` or `hl.q`. Use `hl.fl` +to select fields; if it is omitted, the handler uses `df`. The legacy route is +`/solr/{collection}/highlight`. V2 collection requests use +`/api/c/{collection}/highlight`; core requests use +`/api/cores/{core}/highlight`: + +[source,bash] +---- +curl 'http://localhost:8983/solr/my_collection/highlight?q=body:solr&hl.fl=body' \ + -H 'Content-Type: text/plain' --data-binary 'Solr highlights this text.' +---- + +The response uses the usual `highlighting` shape, with the supplied text under +the `text` key and one snippet for each matching field. The input is one +passage, and the handler does not require a `uniqueKey`. `hl.maxAnalyzedChars` +defaults to 51,200 and cannot exceed 1,048,576; longer input is truncated. + +For example, a matching body can produce this response fragment: + +[source,json] +---- +"highlighting": { + "text": { + "body": ["Solr highlights this text."] + } +} +---- + +The handler accepts only the built-in Lucene query parser. Parser plugins, +local-parameter syntax, and `_query_` subqueries are rejected. Wildcard fields +are not supported. The default configset sets `df` to `_text_`. +`hl.encoder=simple` leaves supplied text unescaped; use `hl.encoder=html` when +rendering snippets as HTML. Custom tag values are markup, so use trusted +content. + == Usage -Highlighting requires that you have a `uniqueKey` defined in your xref:indexing-guide:schema-elements.adoc[schema]. +Search-result highlighting requires that you have a `uniqueKey` defined in your xref:indexing-guide:schema-elements.adoc[schema]. === Common Highlighter Parameters You need to set the `hl` and often `hl.fl` parameters to enable highlighting results to be returned.