From 4ab67eabb395a9d28e39e5fafa9bd302d30a430b Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Sun, 18 Oct 2026 20:52:34 -0400 Subject: [PATCH 01/12] SOLR-18555: add standalone text highlight handler --- changelog/unreleased/SOLR-18555.yml | 5 + .../solr/handler/HighlightRequestHandler.java | 233 ++++++++++++++++++ .../configsets/_default/conf/solrconfig.xml | 5 +- .../query-guide/pages/highlighting.adoc | 42 +++- 4 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 changelog/unreleased/SOLR-18555.yml create mode 100644 solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java diff --git a/changelog/unreleased/SOLR-18555.yml b/changelog/unreleased/SOLR-18555.yml new file mode 100644 index 000000000000..f2b8ff4e6d0d --- /dev/null +++ b/changelog/unreleased/SOLR-18555.yml @@ -0,0 +1,5 @@ +# 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: new_features +authors: + - name: Nick Shanin 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..1899adec234a --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java @@ -0,0 +1,233 @@ +/* + * 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.EnumSet; +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.UnifiedHighlighter.HighlightFlag; +import org.apache.lucene.search.uhighlight.WholeBreakIterator; +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.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.security.AuthorizationContext; + +/** Highlights supplied plain text with a query without searching indexed documents. */ +public class HighlightRequestHandler extends RequestHandlerBase { + + private static final String TEXT_RESPONSE_KEY = "text"; + private static final String STREAM_BODY_PARAM = "stream.body"; + + @Override + public String getDescription() { + return "Highlights supplied text without searching the index."; + } + + @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"); + } + + 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, QParserPlugin.DEFAULT_QTYPE)); + QParser parser = QParser.getParser(queryString, parserType, 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 reader.readNChars(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 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) + .withBreakIterator(WholeBreakIterator::new) + .withHandleMultiTermQuery(true) + .withHighlightPhrasesStrictly(true) + .withPassageRelevancyOverSpeed(true) + .withWeightMatches(false); + + 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); + if (params.getFieldBool(field, HighlightParams.HIGHLIGHT_MULTI_TERM, true)) { + flags.add(HighlightFlag.MULTI_TERM_QUERY); + } + if (params.getFieldBool(field, HighlightParams.USE_PHRASE_HIGHLIGHTER, true)) { + flags.add(HighlightFlag.PHRASES); + } + flags.add(HighlightFlag.PASSAGE_RELEVANCY_OVER_SPEED); + return flags; + } + + @Override + protected Predicate getFieldMatcher(String field) { + return params.getFieldBool(field, HighlightParams.FIELD_MATCH, false) + ? field::equals + : ignored -> true; + } + }; + } +} 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..16e569be20fa 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,49 @@ 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 core `/highlight` request handler highlights text supplied with a request, +without searching documents in the index. Send the text in a `text/plain` +request body and provide a query with `q` or `hl.q`, plus a field with `hl.fl` +or `df`: + +[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 same `highlighting` section and per-field string arrays +as the `HighlightComponent`. Since the request does not identify an indexed +document, the result is stored under the synthetic key `text`. The supplied +body is treated as one passage, so a matching query highlights the whole text +without dividing it into document fragments. `hl.maxAnalyzedChars` limits how +much of the input is read; it defaults to the same limit as search-result +highlighting. A GET request can supply the text with the `stream.body` request +parameter instead of a body. + +For example, a matching body can produce this response fragment: + +[source,json] +---- +"highlighting": { + "text": { + "body": ["Solr highlights this text."] + } +} +---- + +The handler accepts the normal query parser and highlighting formatting +parameters, including `hl.qparser`, `hl.tag.pre`, `hl.tag.post`, and +`hl.encoder`. It requires a query. `hl.fl` selects the field or fields to +highlight and otherwise falls back to `df`; the default config supplies +`_text_` for `df`. Wildcard field names are not supported because expanding +them would require consulting the index. + == 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. From 8cd397755717e7e668861d5efab6c680e46bf1f9 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Sun, 18 Oct 2026 20:56:23 -0400 Subject: [PATCH 02/12] SOLR-18555: expose text highlighting through v2 --- .../org/apache/solr/handler/HighlightRequestHandler.java | 5 +++++ .../modules/query-guide/pages/highlighting.adoc | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java index 1899adec234a..111fce8ad265 100644 --- a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java @@ -55,6 +55,11 @@ public String getDescription() { return "Highlights supplied text without searching the index."; } + @Override + public Boolean registerV2() { + return true; + } + @Override public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { SolrParams params = req.getParams(); 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 16e569be20fa..f71346ec065a 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc @@ -30,7 +30,8 @@ Nonetheless, highlighting is very simple to use. The core `/highlight` request handler highlights text supplied with a request, without searching documents in the index. Send the text in a `text/plain` request body and provide a query with `q` or `hl.q`, plus a field with `hl.fl` -or `df`: +or `df`. The handler is available through the legacy core route and the +handler-specific V2 route, `/api/c/{collectionName}/highlight`: [source,bash] ---- From 603033256aa4b8818d5b4a2be90e7f9241757b54 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Sun, 18 Oct 2026 21:41:15 -0400 Subject: [PATCH 03/12] SOLR-18555: bound highlight input and add tests --- .../solr/handler/HighlightRequestHandler.java | 28 +- .../handler/HighlightRequestHandlerTest.java | 343 ++++++++++++++++++ .../query-guide/pages/highlighting.adoc | 11 +- 3 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java diff --git a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java index 111fce8ad265..9a1f26cb9634 100644 --- a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java @@ -44,15 +44,16 @@ import org.apache.solr.search.QueryParsing; import org.apache.solr.security.AuthorizationContext; -/** Highlights supplied plain text with a query without searching indexed documents. */ +/** 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 without searching the index."; + return "Highlights supplied text with a query."; } @Override @@ -69,6 +70,11 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw 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)); @@ -86,6 +92,9 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw params.get( HighlightParams.QPARSER, params.get(QueryParsing.DEFTYPE, QParserPlugin.DEFAULT_QTYPE)); + // TODO: Some configured QParser plugins consult the index while parsing. The highlighter + // itself does not use a searcher, but a strict no-index guarantee requires restricting or + // otherwise isolating parser plugins before accepting arbitrary parser types here. QParser parser = QParser.getParser(queryString, parserType, req); Query query = parser.getHighlightQuery(); if (query == null) { @@ -149,7 +158,7 @@ private static String readText(SolrQueryRequest req, int maxChars) throws IOExce "HighlightRequestHandler requires a text/plain content stream"); } try (Reader reader = contentStream.getReader()) { - return reader.readNChars(maxChars); + return readUpTo(reader, maxChars); } } } @@ -165,6 +174,19 @@ private static String readText(SolrQueryRequest req, int maxChars) throws IOExce + "' 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) { 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..454c2896fb87 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java @@ -0,0 +1,343 @@ +/* + * 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 = + params( + 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 = + params( + 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 = params(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 = + params( + 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 = + params(CommonParams.Q, "text:\"solr highlights\"", HighlightParams.FIELDS, "text"); + SolrQueryResponse phraseResponse = handle(phraseParams, content); + assertArrayEquals( + new String[] {"Solr highlights the text."}, snippets(phraseResponse, "text")); + + ModifiableSolrParams prefixParams = + params(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 = + params( + 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 = + params( + 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 = + params(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 = params(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 = params(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 = + params(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 = + params( + 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 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 (RequestHandlerBase) + Class.forName("org.apache.solr.handler.HighlightRequestHandler") + .getConstructor() + .newInstance(); + } catch (ReflectiveOperationException e) { + throw new AssertionError("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 params(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/solr-ref-guide/modules/query-guide/pages/highlighting.adoc b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc index f71346ec065a..62eaaab32818 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc @@ -45,8 +45,8 @@ document, the result is stored under the synthetic key `text`. The supplied body is treated as one passage, so a matching query highlights the whole text without dividing it into document fragments. `hl.maxAnalyzedChars` limits how much of the input is read; it defaults to the same limit as search-result -highlighting. A GET request can supply the text with the `stream.body` request -parameter instead of a body. +highlighting and cannot exceed 1,048,576 characters. A GET request can supply +the text with the `stream.body` request parameter instead of a body. For example, a matching body can produce this response fragment: @@ -61,7 +61,12 @@ For example, a matching body can produce this response fragment: The handler accepts the normal query parser and highlighting formatting parameters, including `hl.qparser`, `hl.tag.pre`, `hl.tag.post`, and -`hl.encoder`. It requires a query. `hl.fl` selects the field or fields to +`hl.encoder`. The default `hl.encoder=simple` leaves supplied text unescaped; +use `hl.encoder=html` when rendering the returned snippets as HTML. Custom tag +values are returned as markup and should only contain trusted content. Some +query parser plugins may consult the index while parsing, so the no-search +behavior applies to the highlighting path and to parsers that do not access +indexed documents. It requires a query. `hl.fl` selects the field or fields to highlight and otherwise falls back to `df`; the default config supplies `_text_` for `df`. Wildcard field names are not supported because expanding them would require consulting the index. From 884de51579dc39a196292bbf7184b6aa2601099b Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Sun, 18 Oct 2026 22:35:02 -0400 Subject: [PATCH 04/12] SOLR-18555: restrict parser and support QUERY routes --- .../solr/handler/HighlightRequestHandler.java | 67 +++++++++-- .../HighlightRequestHandlerHttpTest.java | 106 ++++++++++++++++++ .../handler/HighlightRequestHandlerTest.java | 59 ++++++++++ .../query-guide/pages/highlighting.adoc | 23 ++-- .../apache/solr/client/solrj/SolrRequest.java | 2 + 5 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java diff --git a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java index 9a1f26cb9634..4fbcb1a641ef 100644 --- a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java @@ -19,7 +19,9 @@ 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; @@ -28,6 +30,8 @@ import org.apache.lucene.search.uhighlight.UnifiedHighlighter; import org.apache.lucene.search.uhighlight.UnifiedHighlighter.HighlightFlag; 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; @@ -35,6 +39,7 @@ 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; @@ -42,6 +47,7 @@ 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.security.AuthorizationContext; /** Highlights supplied plain text with a query. */ @@ -61,6 +67,18 @@ 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", "QUERY")); + return spec; + })); + } + @Override public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { SolrParams params = req.getParams(); @@ -88,14 +106,20 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw + "'"); } - String parserType = - params.get( - HighlightParams.QPARSER, - params.get(QueryParsing.DEFTYPE, QParserPlugin.DEFAULT_QTYPE)); - // TODO: Some configured QParser plugins consult the index while parsing. The highlighter - // itself does not use a searcher, but a strict no-index guarantee requires restricting or - // otherwise isolating parser plugins before accepting arbitrary parser types here. - QParser parser = QParser.getParser(queryString, parserType, req); + 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( @@ -257,4 +281,31 @@ protected Predicate getFieldMatcher(String field) { } }; } + + 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/HighlightRequestHandlerHttpTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java new file mode 100644 index 000000000000..87f805730a4f --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.handler; + +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 testPostAndQueryMethodsAcceptPlainTextBody() throws Exception { + String url = + solrTestRule.getBaseUrl() + + "/" + + CORE_NAME + + "/highlight?q=_text_%3Asolr&hl.fl=_text_&wt=json"; + + assertHighlightResponse(sendBodyRequest("POST", url)); + assertHighlightResponse(sendBodyRequest("QUERY", url)); + } + + @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 v2Url = + solrTestRule.getJetty().getBaseURLV2() + "/cores/" + CORE_NAME + "/highlight"; + + assertHighlightResponse( + solrTestRule + .getJetty() + .getSolrClient() + .getHttpClient() + .GET(solrTestRule.getBaseUrl() + "/" + CORE_NAME + "/highlight" + params)); + + assertHighlightResponse( + solrTestRule + .getJetty() + .getSolrClient() + .getHttpClient() + .GET(v2Url + params)); + assertHighlightResponse( + sendBodyRequest("POST", v2Url + "?q=_text_%3Asolr&hl.fl=_text_&wt=json")); + assertHighlightResponse( + sendBodyRequest("QUERY", v2Url + "?q=_text_%3Asolr&hl.fl=_text_&wt=json")); + } + + private static ContentResponse sendBodyRequest(String method, String url) throws Exception { + return solrTestRule + .getJetty() + .getSolrClient() + .getHttpClient() + .newRequest(url) + .method(method) + .body(new StringRequestContent("text/plain; charset=UTF-8", TEXT, 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 index 454c2896fb87..e5f5da519673 100644 --- a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java @@ -31,6 +31,7 @@ import org.apache.solr.request.SolrQueryRequestBase; import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.search.SolrIndexSearcher; +import org.apache.solr.search.SyntaxError; import org.apache.solr.security.PermissionNameProvider.Name; import org.junit.BeforeClass; import org.junit.Test; @@ -256,6 +257,64 @@ public void testRejectsInvalidInputs() throws Exception { assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); } + @Test + public void testRejectsParserOverridesAndParserSubqueries() throws Exception { + ModifiableSolrParams parserOverride = + params( + 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 = + params(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 = + params(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 = + params( + CommonParams.Q, + "text:solr OR _query_:\"{!mlt qf=text}solr\"", + HighlightParams.FIELDS, + "text"); + expectThrows( + SyntaxError.class, + () -> + handle( + subquery, + List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + } + @Test public void testRegistrationAndPermission() { RequestHandlerBase handler = newHandler(); 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 62eaaab32818..8e40106f3a3e 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc @@ -59,17 +59,22 @@ For example, a matching body can produce this response fragment: } ---- -The handler accepts the normal query parser and highlighting formatting -parameters, including `hl.qparser`, `hl.tag.pre`, `hl.tag.post`, and +The handler accepts `POST` and HTTP `QUERY` requests with a `text/plain` body. +It also accepts `GET` requests with the text in `stream.body`. It uses the +built-in Lucene query parser and rejects other parser selections and local +parameter parser syntax so parsing cannot dispatch to an arbitrary query parser +plugin. The handler does not acquire a searcher or execute a document query. +It is available through both the legacy core request-handler path and the V2 +`/api/cores/{core}/highlight` route. + +Supported highlighting options include `hl.tag.pre`, `hl.tag.post`, and `hl.encoder`. The default `hl.encoder=simple` leaves supplied text unescaped; use `hl.encoder=html` when rendering the returned snippets as HTML. Custom tag -values are returned as markup and should only contain trusted content. Some -query parser plugins may consult the index while parsing, so the no-search -behavior applies to the highlighting path and to parsers that do not access -indexed documents. It requires a query. `hl.fl` selects the field or fields to -highlight and otherwise falls back to `df`; the default config supplies -`_text_` for `df`. Wildcard field names are not supported because expanding -them would require consulting the index. +values are returned as markup and should only contain trusted content. The +handler requires a query. `hl.fl` selects the field or fields to highlight and +otherwise falls back to `df`; the default config supplies `_text_` for `df`. +Wildcard field names are not supported because expanding them would require +consulting the index. == Usage diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java index 4e0429e124fd..e0c0c07e3e8a 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java @@ -60,6 +60,7 @@ public Principal getUserPrincipal() { public enum METHOD { GET, + QUERY, HEAD, POST, PUT, @@ -111,6 +112,7 @@ public enum SolrClientContext { public static final Set SUPPORTED_METHODS = Set.of( METHOD.GET.toString(), + METHOD.QUERY.toString(), METHOD.POST.toString(), METHOD.PUT.toString(), METHOD.DELETE.toString()); From 4f7368f53c54c2110dda2895df4467929431b89f Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Mon, 19 Oct 2026 02:33:42 -0400 Subject: [PATCH 05/12] SOLR-18555: preserve phrase matches in highlights --- changelog/unreleased/SOLR-18555.yml | 5 +- .../solr/handler/HighlightRequestHandler.java | 23 ++--- .../HighlightRequestHandlerHttpTest.java | 9 +- .../handler/HighlightRequestHandlerTest.java | 92 +++++++++---------- 4 files changed, 64 insertions(+), 65 deletions(-) diff --git a/changelog/unreleased/SOLR-18555.yml b/changelog/unreleased/SOLR-18555.yml index f2b8ff4e6d0d..9a3210f1e34b 100644 --- a/changelog/unreleased/SOLR-18555.yml +++ b/changelog/unreleased/SOLR-18555.yml @@ -1,5 +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: new_features +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 index 4fbcb1a641ef..1800ce964cfc 100644 --- a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java @@ -48,6 +48,7 @@ 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. */ @@ -99,11 +100,7 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw if (queryString == null || queryString.isBlank()) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "A query is required using '" - + CommonParams.Q - + "' or '" - + HighlightParams.Q - + "'"); + "A query is required using '" + CommonParams.Q + "' or '" + HighlightParams.Q + "'"); } String parserType = params.get(HighlightParams.QPARSER, params.get(QueryParsing.DEFTYPE)); @@ -231,7 +228,7 @@ private static UnifiedHighlighter createHighlighter(SolrQueryRequest req, int ma .withHandleMultiTermQuery(true) .withHighlightPhrasesStrictly(true) .withPassageRelevancyOverSpeed(true) - .withWeightMatches(false); + .withWeightMatches(true); return new UnifiedHighlighter(builder) { @Override @@ -263,13 +260,17 @@ protected PassageFormatter getFormatter(String field) { @Override protected Set getFlags(String field) { Set flags = EnumSet.noneOf(HighlightFlag.class); - if (params.getFieldBool(field, HighlightParams.HIGHLIGHT_MULTI_TERM, true)) { - flags.add(HighlightFlag.MULTI_TERM_QUERY); + 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 (params.getFieldBool(field, HighlightParams.USE_PHRASE_HIGHLIGHTER, true)) { - flags.add(HighlightFlag.PHRASES); + if (!flags.contains(HighlightFlag.MULTI_TERM_QUERY) + || !flags.contains(HighlightFlag.PHRASES)) { + flags.remove(HighlightFlag.WEIGHT_MATCHES); } - flags.add(HighlightFlag.PASSAGE_RELEVANCY_OVER_SPEED); return flags; } diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java index 87f805730a4f..ee6b6f8cccef 100644 --- a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java @@ -63,8 +63,7 @@ public void testPostAndQueryMethodsAcceptPlainTextBody() throws Exception { 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 v2Url = - solrTestRule.getJetty().getBaseURLV2() + "/cores/" + CORE_NAME + "/highlight"; + String v2Url = solrTestRule.getJetty().getBaseURLV2() + "/cores/" + CORE_NAME + "/highlight"; assertHighlightResponse( solrTestRule @@ -74,11 +73,7 @@ public void testGetStreamBodyAndV2Routes() throws Exception { .GET(solrTestRule.getBaseUrl() + "/" + CORE_NAME + "/highlight" + params)); assertHighlightResponse( - solrTestRule - .getJetty() - .getSolrClient() - .getHttpClient() - .GET(v2Url + params)); + solrTestRule.getJetty().getSolrClient().getHttpClient().GET(v2Url + params)); assertHighlightResponse( sendBodyRequest("POST", v2Url + "?q=_text_%3Asolr&hl.fl=_text_&wt=json")); assertHighlightResponse( diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java index e5f5da519673..eaee947028d3 100644 --- a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java @@ -31,7 +31,6 @@ import org.apache.solr.request.SolrQueryRequestBase; import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.search.SolrIndexSearcher; -import org.apache.solr.search.SyntaxError; import org.apache.solr.security.PermissionNameProvider.Name; import org.junit.BeforeClass; import org.junit.Test; @@ -46,11 +45,7 @@ public static void beforeClass() throws Exception { @Test public void testTextPlainResponseShapeWithoutIndexedDocuments() throws Exception { ModifiableSolrParams params = - params( - CommonParams.Q, - "text:solr", - HighlightParams.FIELDS, - "text"); + paramsSet(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text"); SolrQueryResponse response = handle( params, @@ -63,7 +58,7 @@ public void testTextPlainResponseShapeWithoutIndexedDocuments() throws Exception @Test public void testGetStreamBodyAndHighlightQueryParameter() throws Exception { ModifiableSolrParams params = - params( + paramsSet( HighlightParams.Q, "text:solr", HighlightParams.FIELDS, @@ -79,7 +74,7 @@ public void testGetStreamBodyAndHighlightQueryParameter() throws Exception { @Test public void testDefaultFieldFallback() throws Exception { - ModifiableSolrParams params = params(CommonParams.Q, "solr", CommonParams.DF, "text"); + ModifiableSolrParams params = paramsSet(CommonParams.Q, "solr", CommonParams.DF, "text"); SolrQueryResponse response = handle( params, @@ -92,7 +87,7 @@ public void testDefaultFieldFallback() throws Exception { @Test public void testHtmlEncoderEscapesSuppliedText() throws Exception { ModifiableSolrParams params = - params( + paramsSet( CommonParams.Q, "text:solr", HighlightParams.FIELDS, @@ -108,7 +103,7 @@ public void testHtmlEncoderEscapesSuppliedText() throws Exception { "Solr", "text/plain; charset=UTF-8"))); assertArrayEquals( - new String[] {"<strong>Solr</strong>"}, snippets(response, "text")); + new String[] {"<strong>Solr</strong>"}, snippets(response, "text")); } @Test @@ -116,13 +111,26 @@ public void testPhraseAndMultiTermQueries() throws Exception { List content = List.of(new ContentStreamBase.StringStream("Solr highlights the text.", "text/plain")); ModifiableSolrParams phraseParams = - params(CommonParams.Q, "text:\"solr highlights\"", HighlightParams.FIELDS, "text"); + 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 = - params(CommonParams.Q, "text:sol*", HighlightParams.FIELDS, "text"); + paramsSet(CommonParams.Q, "text:sol*", HighlightParams.FIELDS, "text"); SolrQueryResponse prefixResponse = handle(prefixParams, content); assertArrayEquals( new String[] {"Solr highlights the text."}, snippets(prefixResponse, "text")); @@ -131,7 +139,7 @@ public void testPhraseAndMultiTermQueries() throws Exception { @Test public void testDefaultSummaryReturnsWholeInputWhenThereIsNoMatch() throws Exception { ModifiableSolrParams params = - params( + paramsSet( CommonParams.Q, "text:missing", HighlightParams.FIELDS, @@ -143,14 +151,13 @@ public void testDefaultSummaryReturnsWholeInputWhenThereIsNoMatch() throws Excep params, List.of(new ContentStreamBase.StringStream("Solr highlights the text.", "text/plain"))); - assertArrayEquals( - new String[] {"Solr highlights the text."}, snippets(response, "text")); + assertArrayEquals(new String[] {"Solr highlights the text."}, snippets(response, "text")); } @Test public void testLengthLimitTruncatesAndRejectsOversizedLimit() throws Exception { ModifiableSolrParams params = - params( + paramsSet( CommonParams.Q, "text:solr", HighlightParams.FIELDS, @@ -174,9 +181,7 @@ public Reader getReader() { expectThrows( SolrException.class, () -> - handle( - params, - List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + 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")); } @@ -184,7 +189,7 @@ public Reader getReader() { @Test public void testRejectsInvalidInputs() throws Exception { ModifiableSolrParams params = - params(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text"); + paramsSet(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text"); SolrException exception = expectThrows( SolrException.class, @@ -208,28 +213,24 @@ public void testRejectsInvalidInputs() throws Exception { exception = expectThrows(SolrException.class, () -> handle(params, null)); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); - ModifiableSolrParams noQuery = params(HighlightParams.FIELDS, "text"); + ModifiableSolrParams noQuery = paramsSet(HighlightParams.FIELDS, "text"); exception = expectThrows( SolrException.class, () -> - handle( - noQuery, - List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + handle(noQuery, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); - ModifiableSolrParams noField = params(CommonParams.Q, "text:solr"); + ModifiableSolrParams noField = paramsSet(CommonParams.Q, "text:solr"); exception = expectThrows( SolrException.class, () -> - handle( - noField, - List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + handle(noField, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); ModifiableSolrParams wildcardField = - params(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text*"); + paramsSet(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text*"); exception = expectThrows( SolrException.class, @@ -240,7 +241,7 @@ public void testRejectsInvalidInputs() throws Exception { assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, exception.code()); ModifiableSolrParams invalidLimit = - params( + paramsSet( CommonParams.Q, "text:solr", HighlightParams.FIELDS, @@ -260,7 +261,7 @@ public void testRejectsInvalidInputs() throws Exception { @Test public void testRejectsParserOverridesAndParserSubqueries() throws Exception { ModifiableSolrParams parserOverride = - params( + paramsSet( CommonParams.Q, "text:solr", HighlightParams.FIELDS, @@ -278,7 +279,7 @@ public void testRejectsParserOverridesAndParserSubqueries() throws Exception { assertTrue(exception.getMessage().contains("supports only")); ModifiableSolrParams defTypeOverride = - params(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text", "defType", "mlt"); + paramsSet(CommonParams.Q, "text:solr", HighlightParams.FIELDS, "text", "defType", "mlt"); exception = expectThrows( SolrException.class, @@ -290,7 +291,7 @@ public void testRejectsParserOverridesAndParserSubqueries() throws Exception { assertTrue(exception.getMessage().contains("supports only")); ModifiableSolrParams localParamsQuery = - params(CommonParams.Q, "{!mlt qf=text}solr", HighlightParams.FIELDS, "text"); + paramsSet(CommonParams.Q, "{!mlt qf=text}solr", HighlightParams.FIELDS, "text"); exception = expectThrows( SolrException.class, @@ -302,17 +303,16 @@ public void testRejectsParserOverridesAndParserSubqueries() throws Exception { assertTrue(exception.getMessage().contains("Local-parameter")); ModifiableSolrParams subquery = - params( + paramsSet( CommonParams.Q, "text:solr OR _query_:\"{!mlt qf=text}solr\"", HighlightParams.FIELDS, "text"); - expectThrows( - SyntaxError.class, - () -> - handle( - subquery, - List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + SolrException subqueryException = + expectThrows( + SolrException.class, + () -> handle(subquery, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, subqueryException.code()); } @Test @@ -337,12 +337,12 @@ private static SolrQueryResponse handle( private static RequestHandlerBase newHandler() { try { - return (RequestHandlerBase) - Class.forName("org.apache.solr.handler.HighlightRequestHandler") - .getConstructor() - .newInstance(); + return Class.forName("org.apache.solr.handler.HighlightRequestHandler") + .asSubclass(RequestHandlerBase.class) + .getConstructor() + .newInstance(); } catch (ReflectiveOperationException e) { - throw new AssertionError("The text highlight request handler must be available", e); + throw new LinkageError("The text highlight request handler must be available", e); } } @@ -354,7 +354,7 @@ private static String[] snippets(SolrQueryResponse response, String field) { return (String[]) document.get(field); } - private static ModifiableSolrParams params(String... nameValues) { + 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]); From 79909b2bd10bb679e44bb475f76134da061ba221 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Mon, 19 Oct 2026 02:40:45 -0400 Subject: [PATCH 06/12] SOLR-18555: remove shadowed highlighter import --- .../java/org/apache/solr/handler/HighlightRequestHandler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java index 1800ce964cfc..59f9556774d5 100644 --- a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java @@ -28,7 +28,6 @@ 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.UnifiedHighlighter.HighlightFlag; import org.apache.lucene.search.uhighlight.WholeBreakIterator; import org.apache.solr.api.Api; import org.apache.solr.api.ApiBag; From b99259cba3572cc08586b2456c78780c814a2bf7 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Mon, 19 Oct 2026 07:13:10 +0000 Subject: [PATCH 07/12] SOLR-18555: gate repair: apply tidy output --- .../apache/solr/handler/HighlightRequestHandlerTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java index eaee947028d3..6974eab16598 100644 --- a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerTest.java @@ -103,7 +103,8 @@ public void testHtmlEncoderEscapesSuppliedText() throws Exception { "Solr", "text/plain; charset=UTF-8"))); assertArrayEquals( - new String[] {"<strong>Solr</strong>"}, snippets(response, "text")); + new String[] {"<strong>Solr</strong>"}, + snippets(response, "text")); } @Test @@ -311,7 +312,9 @@ public void testRejectsParserOverridesAndParserSubqueries() throws Exception { SolrException subqueryException = expectThrows( SolrException.class, - () -> handle(subquery, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); + () -> + handle( + subquery, List.of(new ContentStreamBase.StringStream("Solr", "text/plain")))); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, subqueryException.code()); } From 59a44dbaf10c627d340f23f5481fdb2ba716f089 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Mon, 19 Oct 2026 20:57:56 -0400 Subject: [PATCH 08/12] SOLR-18555: restrict highlighting methods to GET and POST --- .../solr/handler/HighlightRequestHandler.java | 3 +- .../HighlightRequestHandlerHttpTest.java | 41 ++++++++++++++--- .../query-guide/pages/highlighting.adoc | 46 +++++++------------ .../apache/solr/client/solrj/SolrRequest.java | 2 - 4 files changed, 52 insertions(+), 40 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java index 59f9556774d5..9d21d9745bba 100644 --- a/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/HighlightRequestHandler.java @@ -74,7 +74,7 @@ public Collection getApis() { this, () -> { var spec = ApiBag.HANDLER_NAME_SPEC_PROVIDER.getSpec(); - spec.put("methods", List.of("GET", "POST", "QUERY")); + spec.put("methods", List.of("GET", "POST")); return spec; })); } @@ -223,7 +223,6 @@ private static UnifiedHighlighter createHighlighter(SolrQueryRequest req, int ma UnifiedHighlighter.Builder builder = UnifiedHighlighter.builderWithoutSearcher(req.getSchema().getIndexAnalyzer()) .withMaxLength(maxChars) - .withBreakIterator(WholeBreakIterator::new) .withHandleMultiTermQuery(true) .withHighlightPhrasesStrictly(true) .withPassageRelevancyOverSpeed(true) diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java index ee6b6f8cccef..79dafc179d73 100644 --- a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java @@ -48,7 +48,7 @@ public static void startSolr() throws Exception { } @Test - public void testPostAndQueryMethodsAcceptPlainTextBody() throws Exception { + public void testPostAcceptsPlainTextBody() throws Exception { String url = solrTestRule.getBaseUrl() + "/" @@ -56,14 +56,36 @@ public void testPostAndQueryMethodsAcceptPlainTextBody() throws Exception { + "/highlight?q=_text_%3Asolr&hl.fl=_text_&wt=json"; assertHighlightResponse(sendBodyRequest("POST", url)); - assertHighlightResponse(sendBodyRequest("QUERY", 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 v2Url = solrTestRule.getJetty().getBaseURLV2() + "/cores/" + CORE_NAME + "/highlight"; + String v2BaseUrl = solrTestRule.getJetty().getBaseURLV2(); + String v2CoreUrl = v2BaseUrl + "/cores/" + CORE_NAME + "/highlight"; + String v2CollectionUrl = v2BaseUrl + "/c/" + CORE_NAME + "/highlight"; assertHighlightResponse( solrTestRule @@ -73,21 +95,26 @@ public void testGetStreamBodyAndV2Routes() throws Exception { .GET(solrTestRule.getBaseUrl() + "/" + CORE_NAME + "/highlight" + params)); assertHighlightResponse( - solrTestRule.getJetty().getSolrClient().getHttpClient().GET(v2Url + params)); + solrTestRule.getJetty().getSolrClient().getHttpClient().GET(v2CoreUrl + params)); assertHighlightResponse( - sendBodyRequest("POST", v2Url + "?q=_text_%3Asolr&hl.fl=_text_&wt=json")); + solrTestRule.getJetty().getSolrClient().getHttpClient().GET(v2CollectionUrl + params)); assertHighlightResponse( - sendBodyRequest("QUERY", v2Url + "?q=_text_%3Asolr&hl.fl=_text_&wt=json")); + 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("text/plain; charset=UTF-8", TEXT, StandardCharsets.UTF_8)) + .body(new StringRequestContent(contentType, body, StandardCharsets.UTF_8)) .send(); } 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 8e40106f3a3e..05b7118f3c4d 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc @@ -27,11 +27,13 @@ Nonetheless, highlighting is very simple to use. == Highlighting supplied text -The core `/highlight` request handler highlights text supplied with a request, -without searching documents in the index. Send the text in a `text/plain` -request body and provide a query with `q` or `hl.q`, plus a field with `hl.fl` -or `df`. The handler is available through the legacy core route and the -handler-specific V2 route, `/api/c/{collectionName}/highlight`: +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] ---- @@ -39,14 +41,10 @@ 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 same `highlighting` section and per-field string arrays -as the `HighlightComponent`. Since the request does not identify an indexed -document, the result is stored under the synthetic key `text`. The supplied -body is treated as one passage, so a matching query highlights the whole text -without dividing it into document fragments. `hl.maxAnalyzedChars` limits how -much of the input is read; it defaults to the same limit as search-result -highlighting and cannot exceed 1,048,576 characters. A GET request can supply -the text with the `stream.body` request parameter instead of a body. +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: @@ -59,22 +57,12 @@ For example, a matching body can produce this response fragment: } ---- -The handler accepts `POST` and HTTP `QUERY` requests with a `text/plain` body. -It also accepts `GET` requests with the text in `stream.body`. It uses the -built-in Lucene query parser and rejects other parser selections and local -parameter parser syntax so parsing cannot dispatch to an arbitrary query parser -plugin. The handler does not acquire a searcher or execute a document query. -It is available through both the legacy core request-handler path and the V2 -`/api/cores/{core}/highlight` route. - -Supported highlighting options include `hl.tag.pre`, `hl.tag.post`, and -`hl.encoder`. The default `hl.encoder=simple` leaves supplied text unescaped; -use `hl.encoder=html` when rendering the returned snippets as HTML. Custom tag -values are returned as markup and should only contain trusted content. The -handler requires a query. `hl.fl` selects the field or fields to highlight and -otherwise falls back to `df`; the default config supplies `_text_` for `df`. -Wildcard field names are not supported because expanding them would require -consulting the index. +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 diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java index e0c0c07e3e8a..4e0429e124fd 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java @@ -60,7 +60,6 @@ public Principal getUserPrincipal() { public enum METHOD { GET, - QUERY, HEAD, POST, PUT, @@ -112,7 +111,6 @@ public enum SolrClientContext { public static final Set SUPPORTED_METHODS = Set.of( METHOD.GET.toString(), - METHOD.QUERY.toString(), METHOD.POST.toString(), METHOD.PUT.toString(), METHOD.DELETE.toString()); From 519ca48ad568e4e5175b6b814479a8623eebc064 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 20 Oct 2026 04:53:51 +0000 Subject: [PATCH 09/12] SOLR-18555: gate repair: apply tidy output to HighlightRequestHandlerHttpTest --- .../apache/solr/handler/HighlightRequestHandlerHttpTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java index 79dafc179d73..bf2d3bd46935 100644 --- a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java @@ -61,8 +61,7 @@ public void testPostAcceptsPlainTextBody() throws Exception { @Test public void testQueryMethodCannotReachUpdateHandler() throws Exception { String url = solrTestRule.getBaseUrl() + "/" + CORE_NAME + "/update?wt=json"; - String update = - "query-method-must-not-update"; + String update = "query-method-must-not-update"; ContentResponse response = sendBodyRequest("QUERY", url, "application/xml", update); From 2c54781a6ea1c8a5d17b802fbc97fed18005aa2b Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 20 Oct 2026 05:10:19 +0000 Subject: [PATCH 10/12] SOLR-18555: gate repair: convert V2 base URL to String in HighlightRequestHandlerHttpTest --- .../apache/solr/handler/HighlightRequestHandlerHttpTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java index bf2d3bd46935..4113d0d0b90b 100644 --- a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java @@ -82,7 +82,7 @@ public void testQueryMethodIsNotRegisteredForV2Handler() throws Exception { 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(); + String v2BaseUrl = solrTestRule.getJetty().getBaseURLV2().toString(); String v2CoreUrl = v2BaseUrl + "/cores/" + CORE_NAME + "/highlight"; String v2CollectionUrl = v2BaseUrl + "/c/" + CORE_NAME + "/highlight"; From 053f37a0950a3fe4e896d09770ca50090189a89d Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 20 Oct 2026 07:50:28 +0000 Subject: [PATCH 11/12] SOLR-18555: gate repair: collection route runs in a cloud-capable test --- .../apache/solr/handler/HighlightRequestHandlerHttpTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java index 4113d0d0b90b..7b521b592b9c 100644 --- a/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java +++ b/solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerHttpTest.java @@ -84,7 +84,6 @@ public void testGetStreamBodyAndV2Routes() throws Exception { 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"; - String v2CollectionUrl = v2BaseUrl + "/c/" + CORE_NAME + "/highlight"; assertHighlightResponse( solrTestRule @@ -95,8 +94,6 @@ public void testGetStreamBodyAndV2Routes() throws Exception { assertHighlightResponse( solrTestRule.getJetty().getSolrClient().getHttpClient().GET(v2CoreUrl + params)); - assertHighlightResponse( - solrTestRule.getJetty().getSolrClient().getHttpClient().GET(v2CollectionUrl + params)); assertHighlightResponse( sendBodyRequest("POST", v2CoreUrl + "?q=_text_%3Asolr&hl.fl=_text_&wt=json")); } From 39c1b546ef44db53466671dba6a7b3cdfc83422f Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Tue, 20 Oct 2026 07:50:28 +0000 Subject: [PATCH 12/12] SOLR-18555: gate repair: cloud-capable test for the v2 collection route --- .../HighlightRequestHandlerCloudHttpTest.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 solr/core/src/test/org/apache/solr/handler/HighlightRequestHandlerCloudHttpTest.java 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")); + } +}