Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
title: Admin UI's SQL screen no longer crashes with an uncaught JS error (and silently shows a blank result grid) when the response isn't a SQL result-set - e.g. when the sql module/handler isn't installed. It now shows the server's error message instead.
type: fixed
authors:
- name: Eric Pugh
links:
- name: SOLR-16640
url: https://issues.apache.org/jira/browse/SOLR-16640
19 changes: 19 additions & 0 deletions dev-docs/apis.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ Writing a new v2 API may appear daunting, but additions in reality are actually

A good example for each of these steps can be seen in Solr's v2 "add-replica-property" API, which has a defining interface https://github.com/apache/solr/blob/9426902acb7081a2e9a1fa29699c5286459e1365/solr/api/src/java/org/apache/solr/client/api/endpoint/AddReplicaPropertyApi.java[AddReplicaPropertyApi], an implementing class https://github.com/apache/solr/blob/9426902acb7081a2e9a1fa29699c5286459e1365/solr/core/src/java/org/apache/solr/handler/admin/api/AddReplicaProperty.java[AddReplicaProperty], and the two POJOs https://github.com/apache/solr/blob/main/solr/api/src/java/org/apache/solr/client/api/model/AddReplicaPropertyRequestBody.java[AddReplicaPropertyRequestBody] and https://github.com/apache/solr/blob/main/solr/api/src/java/org/apache/solr/client/api/model/SolrJerseyResponse.java[SolrJerseyResponse].

==== HTTP QUERY spike

Use `QUERY` for safe, idempotent queries whose inputs belong in a request body, as defined by https://www.rfc-editor.org/rfc/rfc10008.html[RFC 10008].
For example, a search with a structured JSON body containing filters, facets, and sorting is a candidate when expressing those inputs as URL parameters would be cumbersome or exceed URL length limits.
The operation must not request changes to documents, schemas, or cluster configuration, and retrying it must not cause additional mutations.
Results can still change between requests as the underlying index changes; idempotence does not require identical results.

Prefer `GET` for simple reads or searches that fit naturally in the URL.
Use `POST`, `PUT`, or `DELETE` for operations that request state changes, even if their inputs contain a query (such as delete-by-query).
For body-based reads, `QUERY` communicates safe retry semantics that `POST` does not; retain `POST` where client or intermediary compatibility requires it.
Only use `QUERY` on endpoints that explicitly support it, with a `Content-Type` matching the query body, such as `application/json`.

The `QUERY` annotation in the `api` module uses JAX-RS's `@HttpMethod("QUERY")` extension mechanism.
Jetty can carry the method as a string without an entry in its `HttpMethod` enum.

The AngularJS Admin UI's SQL screen offers a GET/POST/QUERY selector for testing against the read-only V1 `/sql` handler; POST is the default.
GET sends the SQL statement as a URL parameter; POST and QUERY send it in a form-encoded body.
Other SolrJ transports, distributed forwarding, authorization policies, OpenAPI generation, and QUERY cache semantics still need validation before adopting it for a public API.

=== Relationship Between V1 and V2 Implementations

Most v2 APIs have a corresponding legacy v1 API (e.g. `/admin/cores?action=RELOAD` backs `POST /api/cores/coreName/reload`).
Expand Down
30 changes: 30 additions & 0 deletions solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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.client.api.endpoint;

import jakarta.ws.rs.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/** Marks a JAX-RS resource method as accepting HTTP QUERY requests. */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@HttpMethod("QUERY")
public @interface QUERY {}
2 changes: 1 addition & 1 deletion solr/core/src/java/org/apache/solr/cli/RunExampleTool.java
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,7 @@ Map<String, Object> startSolr(

final var syspropArg =
("techproducts".equals(params.example()))
? "-Dsolr.modules=clustering,extraction,langid,ltr,scripting -Dsolr.ltr.enabled=true -Dsolr.clustering.enabled=true"
? "-Dsolr.modules=clustering,extraction,langid,ltr,scripting,sql -Dsolr.ltr.enabled=true -Dsolr.clustering.enabled=true"
: "";

String startCmdStr =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* 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.jersey;

import static org.apache.solr.SolrTestCaseJ4.TEST_PATH;
import static org.apache.solr.SolrTestCaseJ4.copyMinConf;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.apache.solr.SolrTestCase;
import org.apache.solr.api.JerseyResource;
import org.apache.solr.client.api.endpoint.QUERY;
import org.apache.solr.client.solrj.SolrRequest;
import org.apache.solr.client.solrj.jetty.HttpJettySolrClient;
import org.apache.solr.client.solrj.request.V2Request;
import org.apache.solr.client.solrj.response.json.JsonMapResponseParser;
import org.apache.solr.common.util.Utils;
import org.apache.solr.handler.RequestHandlerBase;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.response.SolrQueryResponse;
import org.apache.solr.security.AuthorizationContext;
import org.apache.solr.security.PermissionNameProvider;
import org.apache.solr.util.SolrJettyTestRule;
import org.eclipse.jetty.client.StringRequestContent;
import org.eclipse.jetty.http.HttpVersion;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;

/** Exercises an extension HTTP method through Jetty, Solr dispatch, and Jersey. */
public class HttpQueryIntegrationTest extends SolrTestCase {
@ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule();

@BeforeClass
public static void setupSolr() throws Exception {
final var solrHome = createTempDir();
Files.copy(TEST_PATH().resolve("solr.xml"), solrHome.resolve("solr.xml"));
final var coreDir = solrHome.resolve("collection1");
copyMinConf(coreDir, "name=collection1\n", "solrconfig-minimal.xml");
final var config = coreDir.resolve("conf/solrconfig.xml");
Files.writeString(
config,
Files.readString(config)
.replaceAll("<xi:include[^>]*/>", "")
.replace(
"</config>",
"<requestHandler name=\"/query-spike-registration\" class=\""
+ QueryHandler.class.getName()
+ "\"/>\n</config>"));
solrTestRule.startSolr(solrHome);
}

@Test
public void testQueryWithJsonBody() throws Exception {
for (var version : List.of(HttpVersion.HTTP_1_1, HttpVersion.HTTP_2)) {
try (var solrClient =
new HttpJettySolrClient.Builder(solrTestRule.getBaseUrl())
.useHttp1_1(version == HttpVersion.HTTP_1_1)
.build()) {
final var response =
solrClient
.getHttpClient()
.newRequest(queryUrl())
.version(version)
.method("QUERY")
.body(
new StringRequestContent(
"application/json", "{\"query\":\"id:42\"}", StandardCharsets.UTF_8))
.send();
assertEquals(response.getContentAsString(), 200, response.getStatus());
assertEquals(version, response.getVersion());
assertEquals(
"id:42",
((Map<?, ?>) Utils.fromJSONString(response.getContentAsString())).get("query"));
}
}
}

@Test
public void testQueryThroughSolrJ() throws Exception {
final var request =
new V2Request.Builder("/cores/collection1/query-spike")
.withMethod(SolrRequest.METHOD.QUERY)
.withPayload("{\"query\":\"id:42\"}")
.build();
request.setResponseParser(new JsonMapResponseParser());
assertEquals("id:42", solrTestRule.getJetty().getSolrClient().request(request).get("query"));
}

@Test
public void testPostDoesNotInvokeQueryEndpoint() throws Exception {
final var response =
solrTestRule
.getJetty()
.getSolrClient()
.getHttpClient()
.newRequest(queryUrl())
.method("POST")
.body(
new StringRequestContent(
"application/json", "{\"query\":\"id:42\"}", StandardCharsets.UTF_8))
.send();
assertEquals(response.getContentAsString(), 405, response.getStatus());
}

private String queryUrl() {
return solrTestRule.getJetty().getBaseURLV2() + "/cores/collection1/query-spike";
}

/** Registers the test resource with the core's Jersey application. */
public static class QueryHandler extends RequestHandlerBase {
@Override
public PermissionNameProvider.Name getPermissionName(AuthorizationContext request) {
return PermissionNameProvider.Name.READ_PERM;
}

@Override
public Boolean registerV2() {
return true;
}

@Override
public Collection<Class<? extends JerseyResource>> getJerseyResources() {
return List.of(QueryResource.class);
}

@Override
public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) {}

@Override
public String getDescription() {
return "HTTP QUERY spike";
}
}

/** Test-only V2 endpoint that echoes the JSON query to verify entity handling. */
@Path("/cores/{coreName}/query-spike")
public static class QueryResource extends JerseyResource {
@QUERY
@Consumes("application/json")
@Produces("application/json")
@PermissionName(PermissionNameProvider.Name.READ_PERM)
public Map<String, Object> query(Map<String, Object> body) {
return body;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -719,11 +719,11 @@ private MakeRequestReturnValue makeRequest(
}

if (SolrRequest.METHOD.POST == solrRequest.getMethod()
|| SolrRequest.METHOD.PUT == solrRequest.getMethod()) {
|| SolrRequest.METHOD.PUT == solrRequest.getMethod()
|| SolrRequest.METHOD.QUERY == solrRequest.getMethod()) {
RequestWriter.ContentWriter contentWriter = requestWriter.getContentWriter(solrRequest);

HttpMethod method =
SolrRequest.METHOD.POST == solrRequest.getMethod() ? HttpMethod.POST : HttpMethod.PUT;
String method = solrRequest.getMethod().toString();

if (contentWriter instanceof RequestWriter.MultipartContentWriter multipartWriter) {
// send server list and request list as query string params
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public enum METHOD {
HEAD,
POST,
PUT,
QUERY,
DELETE;

/**
Expand Down Expand Up @@ -113,6 +114,7 @@ public enum SolrClientContext {
METHOD.GET.toString(),
METHOD.POST.toString(),
METHOD.PUT.toString(),
METHOD.QUERY.toString(),
METHOD.DELETE.toString());

private METHOD method = METHOD.GET;
Expand Down
Loading
Loading