From c7a0f425bc47f5dcadb85f9d2d3e4cd486ef40e4 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Tue, 6 Oct 2026 16:37:11 -0400 Subject: [PATCH 1/3] SOLR-9759: Admin UI posts streaming expressions instead of using GET The Stream screen sent the expression as a GET query-string parameter, so a sufficiently large expression could be rejected by the server before ever reaching the stream handler - and the UI had no error handling for a failed request, so it just hung with no feedback. Added a POST-based action to the Query service (form-encoded body, same request shape otherwise) and switched the Stream screen to use it. Both the success and error paths now go through one response handler that falls back to showing the raw body if it isn't JSON, instead of letting an unguarded JSON.parse throw uncaught. --- .../solr-9759-admin-ui-stream-post.yml | 8 ++++ .../solr/webapp/AdminUiStreamScreenTest.java | 40 +++++++++++++++++++ .../web/js/angular/controllers/stream.js | 34 +++++++++------- solr/webapp/web/js/angular/services.js | 34 +++++++++++----- 4 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 changelog/unreleased/solr-9759-admin-ui-stream-post.yml diff --git a/changelog/unreleased/solr-9759-admin-ui-stream-post.yml b/changelog/unreleased/solr-9759-admin-ui-stream-post.yml new file mode 100644 index 000000000000..67bf904139d5 --- /dev/null +++ b/changelog/unreleased/solr-9759-admin-ui-stream-post.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Admin UI's Stream screen now posts the streaming expression as a form-encoded body instead of a GET query-string parameter, so large expressions no longer silently fail, and a failed request now shows an error instead of leaving the screen blank. +type: fixed +authors: + - name: Eric Pugh +links: + - name: SOLR-9759 + url: https://issues.apache.org/jira/browse/SOLR-9759 diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java index b348120bceb6..98341023da3f 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java @@ -21,6 +21,7 @@ import org.junit.BeforeClass; import org.junit.Test; import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.WebElement; /** Tests the Stream screen: executing a streaming expression through the form. */ @@ -51,4 +52,43 @@ public void testStreamingExpressionViaUi() { assertTrue("All docs should stream: " + response, response.contains("stream-doc-3")); assertNoSevereConsoleErrors(); } + + @Test + public void testLargeExpressionSucceedsViaUi() { + // A streaming expression large enough that a GET request's URL/header would be rejected by + // Jetty before ever reaching Solr (SOLR-9759) - a single wildcard clause keeps it one simple + // query (matching nothing, since no real id starts with this), so a clean zero-hit response + // (rather than a hang, a truncated request, or a parse error) confirms the whole POST body + // round-tripped intact. + String padding = "a".repeat(20000); + String expression = + "search(" + COLLECTION + ",q=\"*:*\",fl=\"id\",sort=\"id asc\",fq=\"id:" + padding + "*\")"; + assertTrue("test expression should exceed a typical 8K header/URL limit", expression.length() > 16384); + + openPage(COLLECTION + "/stream", By.id("stream")); + WebElement expr = waitFor(By.id("expr")); + ((JavascriptExecutor) driver) + .executeScript( + "arguments[0].value = arguments[1];" + + "arguments[0].dispatchEvent(new Event('input', {bubbles: true}));", + expr, + expression); + click(By.cssSelector("#stream button[type=submit]")); + waitForTextContains(By.cssSelector("#stream #result"), "EOF"); + assertNoSevereConsoleErrors(); + } + + @Test + public void testFailedRequestShowsErrorInsteadOfHanging() { + // a nonexistent collection makes the request fail - confirms a failed request surfaces + // something in the UI instead of leaving the screen blank forever (the original bug). + openPage("nonexistentcoll/stream", By.id("stream")); + WebElement expr = waitFor(By.id("expr")); + expr.clear(); + expr.sendKeys("search(" + COLLECTION + ",q=\"*:*\",fl=\"id\",sort=\"id asc\")"); + click(By.cssSelector("#stream button[type=submit]")); + waitForTextContains(By.cssSelector("#stream #result"), "no handler, collection, or core"); + // the request is expected to fail (404) - that's the scenario under test + assertNoSevereConsoleErrors("404 (Not Found)"); + } } diff --git a/solr/webapp/web/js/angular/controllers/stream.js b/solr/webapp/web/js/angular/controllers/stream.js index f8001d84ffee..966a7b38f6e4 100644 --- a/solr/webapp/web/js/angular/controllers/stream.js +++ b/solr/webapp/web/js/angular/controllers/stream.js @@ -41,27 +41,33 @@ solrAdminApp.controller('StreamController', $scope.response = null; $scope.url = ""; + // Shown for reference only - the request is actually sent as a POST body below, since a + // streaming expression can be too large for a URL/header (SOLR-9759). var url = Query.url(params); - Query.query(params, function(data) { - - var jsonData = JSON.parse(data.toJSON().data); - if (undefined != jsonData["explanation"]) { - $scope.showExplanation = true; - - streamGraphSubController($scope, jsonData["explanation"]) - delete jsonData["explanation"] - } else { - $scope.showExplanation = false; + var showResult = function(raw) { + $scope.showExplanation = false; + try { + var jsonData = JSON.parse(raw); + if (undefined != jsonData["explanation"]) { + $scope.showExplanation = true; + streamGraphSubController($scope, jsonData["explanation"]); + delete jsonData["explanation"]; + } + raw = JSON.stringify(jsonData, null, 2); + } catch (e) { + // not JSON (e.g. a raw HTTP error page) - show it as-is rather than crashing } - - data.data = JSON.stringify(jsonData,null,2); - $scope.lang = "json"; - $scope.response = data; + $scope.response = {data: raw}; $scope.url = url; $scope.hostPortContext = $location.absUrl().substr(0,$location.absUrl().indexOf("#")); // For display only + }; + Query.queryPost({core: params.core, handler: params.handler}, params, function(data) { + showResult(data.toJSON().data); + }, function(rejection) { + showResult((rejection.data && rejection.data.data) || ("HTTP " + rejection.status + " " + rejection.statusText)); }); }; diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 462d684121e0..ef08578be497 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -355,6 +355,17 @@ solrAdminServices.factory('Metrics', }]) .factory('Query', ['$resource', function($resource) { + var toQueryString = function(params) { + var qs = []; + for (var key in params) { + if (key != "core" && key != "handler") { + for (var i in params[key]) { + qs.push(key + "=" + encodeURIComponent(params[key][i])); + } + } + } + return qs.sort().join("&"); + } var resource = $resource(':core/:handler', {core: '@core', handler: '@handler', '_':Date.now()}, { "query": { method: "GET", @@ -362,18 +373,23 @@ solrAdminServices.factory('Metrics', return {data: data} }, headers: {doNotIntercept: "true"} + }, + // Same request as "query" above, but as a form-encoded POST body instead of a query + // string - for request params (e.g. a streaming expression) too large for a URL/header. + "queryPost": { + method: "POST", + transformRequest: toQueryString, + transformResponse: function (data) { + return {data: data} + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + doNotIntercept: "true" + } } }); resource.url = function(params) { - var qs = []; - for (key in params) { - if (key != "core" && key != "handler") { - for (var i in params[key]) { - qs.push(key + "=" + encodeURIComponent(params[key][i])); - } - } - } - return "" + params.core + "/" + params.handler + "?" + qs.sort().join("&"); + return "" + params.core + "/" + params.handler + "?" + toQueryString(params); } return resource; }]) From a4668576ed2efd01062711a270d23933b35c483a Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 7 Oct 2026 08:04:57 -0400 Subject: [PATCH 2/3] SOLR-9759: Dedupe transformResponse and document the doNotIntercept quirk /simplify pass on the previous commit: "query" and "queryPost" had an identical transformResponse copy-pasted between them - extracted to a shared wrapRawResponse helper. Also added a comment on stream.js's showResult() explaining why it must handle non-JSON bodies even on the "success" path: app.js's global interceptor routes most failures for doNotIntercept requests through that callback too. --- solr/webapp/web/js/angular/controllers/stream.js | 3 +++ solr/webapp/web/js/angular/services.js | 11 +++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/solr/webapp/web/js/angular/controllers/stream.js b/solr/webapp/web/js/angular/controllers/stream.js index 966a7b38f6e4..56faeb4d6d51 100644 --- a/solr/webapp/web/js/angular/controllers/stream.js +++ b/solr/webapp/web/js/angular/controllers/stream.js @@ -45,6 +45,9 @@ solrAdminApp.controller('StreamController', // streaming expression can be too large for a URL/header (SOLR-9759). var url = Query.url(params); + // Handles both the success and error callbacks below: app.js's global interceptor + // (see failed()'s doNotIntercept branch) routes most failures for this request through + // the "success" callback too, so this must defend against a non-JSON body either way. var showResult = function(raw) { $scope.showExplanation = false; try { diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index ef08578be497..a088e1c1c6fe 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -366,12 +366,13 @@ solrAdminServices.factory('Metrics', } return qs.sort().join("&"); } + var wrapRawResponse = function(data) { + return {data: data} + } var resource = $resource(':core/:handler', {core: '@core', handler: '@handler', '_':Date.now()}, { "query": { method: "GET", - transformResponse: function (data) { - return {data: data} - }, + transformResponse: wrapRawResponse, headers: {doNotIntercept: "true"} }, // Same request as "query" above, but as a form-encoded POST body instead of a query @@ -379,9 +380,7 @@ solrAdminServices.factory('Metrics', "queryPost": { method: "POST", transformRequest: toQueryString, - transformResponse: function (data) { - return {data: data} - }, + transformResponse: wrapRawResponse, headers: { 'Content-Type': 'application/x-www-form-urlencoded', doNotIntercept: "true" From 8bab5f1babec8c2cbcb5bc60a4c5c081d0af0e92 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 7 Oct 2026 15:39:33 -0400 Subject: [PATCH 3/3] tidy --- .../test/org/apache/solr/webapp/AdminUiStreamScreenTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java index 98341023da3f..3abec954fdc8 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiStreamScreenTest.java @@ -63,7 +63,8 @@ public void testLargeExpressionSucceedsViaUi() { String padding = "a".repeat(20000); String expression = "search(" + COLLECTION + ",q=\"*:*\",fl=\"id\",sort=\"id asc\",fq=\"id:" + padding + "*\")"; - assertTrue("test expression should exceed a typical 8K header/URL limit", expression.length() > 16384); + assertTrue( + "test expression should exceed a typical 8K header/URL limit", expression.length() > 16384); openPage(COLLECTION + "/stream", By.id("stream")); WebElement expr = waitFor(By.id("expr"));