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 00000000000..67bf904139d --- /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 b348120bceb..3abec954fdc 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,44 @@ 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 f8001d84ffe..56faeb4d6d5 100644 --- a/solr/webapp/web/js/angular/controllers/stream.js +++ b/solr/webapp/web/js/angular/controllers/stream.js @@ -41,27 +41,36 @@ 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; + // 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 { + 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 462d684121e..a088e1c1c6f 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -355,25 +355,40 @@ solrAdminServices.factory('Metrics', }]) .factory('Query', ['$resource', function($resource) { - var resource = $resource(':core/:handler', {core: '@core', handler: '@handler', '_':Date.now()}, { - "query": { - method: "GET", - transformResponse: function (data) { - return {data: data} - }, - headers: {doNotIntercept: "true"} - } - }); - resource.url = function(params) { + var toQueryString = function(params) { var qs = []; - for (key in params) { + for (var 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 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: wrapRawResponse, + 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: wrapRawResponse, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + doNotIntercept: "true" + } + } + }); + resource.url = function(params) { + return "" + params.core + "/" + params.handler + "?" + toQueryString(params); } return resource; }])