Skip to content

Commit 0937bb5

Browse files
authored
SOLR-17562: Unify JAX-RS "raw response" endpoints (#3032)
Creates InputStreamResponse and corresponding tests to wrap the NamedList produced by InputStreamResponseParser. Modifies the codegen mustache template to use this response type for any v2 APIs that are tagged as producing 'rawOutput', including the zk-read and fetch-index-file endpoints.
1 parent af5fea7 commit 0937bb5

File tree

20 files changed

+357
-119
lines changed

20 files changed

+357
-119
lines changed

dev-docs/v2-api-conventions.adoc

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
= API Design
12
== HTTP Paths
23

34
Where possible, each v2 API is given an HTTP path that reflects the resource type and/or name most relevant to its functionality.
@@ -69,8 +70,8 @@ For use within the v2 API, the four "popular" HTTP methods have the following se
6970
== Errors
7071

7172
v2 APIs should be consistent in how they report errors. Throwing a `SolrException` will convey
72-
1.the error code as the HTTP response status code, as `responseHeader.status` and as `error.code`, and
73-
1.the error message as `error.msg`.
73+
1. The error code as the HTTP response status code, as `responseHeader.status` and as `error.code`, and
74+
2. The error message as `error.msg`.
7475

7576
API calls that reference a specific resource (e.g. `specificCollName`, `specificAliasName`, `specificPropertyName` and others per the above list) that do not exist should return `SolrException.ErrorCode.NOT_FOUND` (HTTP 404).
7677

@@ -82,3 +83,23 @@ Often these operations were initially conceived of as procedural "commands" and
8283

8384
Solr's v2 API currently accommodates these "command" APIs by appending the command name (often a verb like "unload", "reload", or "split") onto the otherwise "resource"-based path.
8485
For example: Solr's core "unload" command uses the API `POST /api/cores/specificCoreName/unload`.
86+
87+
= JAX-RS Implementation Conventions
88+
89+
== Streaming
90+
91+
Solr has a number of APIs that return binary file data or other arbitrary content, such as the "replication" APIs used to pull index files from other cores.
92+
Please use the following conventions when implementing similar endpoints:
93+
1. `@Operation` annotations use an "extension property" to indicate to codegen tools that the API output is "raw" or untyped. For example:
94+
+
95+
```
96+
@Operation(
97+
summary = "Return the data stored in a specified ZooKeeper node",
98+
tags = {"zookeeper-read"},
99+
extensions = {
100+
@Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")})
101+
})
102+
```
103+
2. Interface methods should return a type that implements the JAX-RS `StreamingOutput` interface.
104+
105+
See the `fetchFile()` method in `ReplicationApis.java` for a concrete example.

solr/api/build.gradle

+2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ resolve {
5555
outputDir = file(project.openApiSpecDir)
5656
outputFileName = "solr-openapi-${version}"
5757
prettyPrint = true
58+
// Ignore resources not annotated with 'Operation', useful for omitting endpoints from OAS
59+
readAllResources = false
5860
}
5961

6062
dependencies {

solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java

+1
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ SolrJerseyResponse uploadConfigSet(
8989

9090
@PUT
9191
@Path("{filePath:.+}")
92+
@Operation(summary = "Create a new configset.", tags = "configsets")
9293
SolrJerseyResponse uploadConfigSetFile(
9394
@PathParam("configSetName") String configSetName,
9495
@PathParam("filePath") String filePath,

solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java

+4-5
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717
package org.apache.solr.client.api.endpoint;
1818

19-
import static org.apache.solr.client.api.util.Constants.OMIT_FROM_CODEGEN_PROPERTY;
19+
import static org.apache.solr.client.api.util.Constants.RAW_OUTPUT_PROPERTY;
2020

2121
import io.swagger.v3.oas.annotations.Operation;
2222
import io.swagger.v3.oas.annotations.Parameter;
@@ -59,10 +59,9 @@ FileListResponse fetchFileList(
5959
@CoreApiParameters
6060
@Operation(
6161
summary = "Get a stream of a specific file path of a core",
62-
tags = {"core-replication"},
63-
extensions = { // TODO Remove as a part of SOLR-17562
64-
@Extension(
65-
properties = {@ExtensionProperty(name = OMIT_FROM_CODEGEN_PROPERTY, value = "true")})
62+
tags = {"replication"},
63+
extensions = {
64+
@Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")})
6665
})
6766
@Path("/files/{filePath}")
6867
StreamingOutput fetchFile(

solr/api/src/java/org/apache/solr/client/api/endpoint/ZooKeeperReadApis.java

+11-4
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,19 @@
1616
*/
1717
package org.apache.solr.client.api.endpoint;
1818

19+
import static org.apache.solr.client.api.util.Constants.RAW_OUTPUT_PROPERTY;
20+
1921
import io.swagger.v3.oas.annotations.Operation;
2022
import io.swagger.v3.oas.annotations.Parameter;
23+
import io.swagger.v3.oas.annotations.extensions.Extension;
24+
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
2125
import jakarta.ws.rs.GET;
2226
import jakarta.ws.rs.Path;
2327
import jakarta.ws.rs.PathParam;
2428
import jakarta.ws.rs.Produces;
2529
import jakarta.ws.rs.QueryParam;
2630
import jakarta.ws.rs.core.MediaType;
27-
import org.apache.solr.client.api.model.ZooKeeperFileResponse;
31+
import jakarta.ws.rs.core.StreamingOutput;
2832
import org.apache.solr.client.api.model.ZooKeeperListChildrenResponse;
2933

3034
/** V2 API definitions for Solr's ZooKeeper ready-proxy endpoint */
@@ -35,9 +39,12 @@ public interface ZooKeeperReadApis {
3539
@Path("/data{zkPath:.+}")
3640
@Operation(
3741
summary = "Return the data stored in a specified ZooKeeper node",
38-
tags = {"zookeeper-read"})
42+
tags = {"zookeeper-read"},
43+
extensions = {
44+
@Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")})
45+
})
3946
@Produces({"application/vnd.apache.solr.raw", MediaType.APPLICATION_JSON})
40-
ZooKeeperFileResponse readNode(
47+
StreamingOutput readNode(
4148
@Parameter(description = "The path of the node to read from ZooKeeper") @PathParam("zkPath")
4249
String zkPath);
4350

@@ -48,7 +55,7 @@ ZooKeeperFileResponse readNode(
4855
@GET
4956
@Path("/data/security.json")
5057
@Produces({"application/vnd.apache.solr.raw", MediaType.APPLICATION_JSON})
51-
ZooKeeperFileResponse readSecurityJsonNode();
58+
StreamingOutput readSecurityJsonNode();
5259

5360
@GET
5461
@Path("/children{zkPath:.*}")

solr/api/src/java/org/apache/solr/client/api/model/ZooKeeperFileResponse.java

-28
This file was deleted.

solr/api/src/java/org/apache/solr/client/api/util/Constants.java

+5
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ private Constants() {
2929

3030
public static final String CORE_NAME_PATH_PARAMETER = "coreName";
3131

32+
// Annotation used on endpoints that should be skipped by code-generation
3233
public static final String OMIT_FROM_CODEGEN_PROPERTY = "omitFromCodegen";
34+
// Annotation used to indicate that the specified API can return arbitrary, unparseable content
35+
// such as ZK or filestore files
36+
public static final String RAW_OUTPUT_PROPERTY = "rawOutput";
37+
3338
public static final String GENERIC_ENTITY_PROPERTY = "genericEntity";
3439

3540
public static final String BINARY_CONTENT_TYPE_V2 = "application/vnd.apache.solr.javabin";

solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java

+1-4
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,7 @@ public void writeResults(ResultContext ctx, JavaBinCodec codec) throws IOExcepti
308308

309309
if (responseParser instanceof InputStreamResponseParser) {
310310
// SPECIAL CASE
311-
NamedList<Object> namedList = new NamedList<>();
312-
namedList.add("stream", byteBuffer.toInputStream());
313-
namedList.add("responseStatus", 200); // always by this point
314-
return namedList;
311+
return InputStreamResponseParser.createInputStreamNamedList(200, byteBuffer.toInputStream());
315312
}
316313

317314
// note: don't bother using the Reader variant; it often throws UnsupportedOperationException

solr/core/src/java/org/apache/solr/handler/admin/ZookeeperRead.java

+15-12
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,21 @@
2121
import static org.apache.solr.security.PermissionNameProvider.Name.ZK_READ_PERM;
2222

2323
import jakarta.inject.Inject;
24+
import jakarta.ws.rs.WebApplicationException;
25+
import jakarta.ws.rs.core.StreamingOutput;
26+
import java.io.IOException;
27+
import java.io.OutputStream;
2428
import java.util.HashMap;
2529
import java.util.LinkedHashMap;
2630
import java.util.List;
2731
import java.util.Map;
2832
import org.apache.solr.client.api.endpoint.ZooKeeperReadApis;
29-
import org.apache.solr.client.api.model.ZooKeeperFileResponse;
3033
import org.apache.solr.client.api.model.ZooKeeperListChildrenResponse;
3134
import org.apache.solr.client.api.model.ZooKeeperStat;
3235
import org.apache.solr.client.solrj.impl.BinaryResponseParser;
3336
import org.apache.solr.client.solrj.impl.XMLResponseParser;
3437
import org.apache.solr.common.SolrException;
3538
import org.apache.solr.common.params.CommonParams;
36-
import org.apache.solr.common.util.ContentStreamBase;
3739
import org.apache.solr.core.CoreContainer;
3840
import org.apache.solr.handler.admin.api.AdminAPIBase;
3941
import org.apache.solr.jersey.PermissionName;
@@ -64,15 +66,15 @@ public ZookeeperRead(CoreContainer coreContainer, SolrQueryRequest req, SolrQuer
6466
/** Request contents of a znode, except security.json */
6567
@Override
6668
@PermissionName(ZK_READ_PERM)
67-
public ZooKeeperFileResponse readNode(String zkPath) {
69+
public StreamingOutput readNode(String zkPath) {
6870
zkPath = sanitizeZkPath(zkPath);
6971
return readNodeAndAddToResponse(zkPath);
7072
}
7173

7274
/** Request contents of the security.json node */
7375
@Override
7476
@PermissionName(SECURITY_READ_PERM)
75-
public ZooKeeperFileResponse readSecurityJsonNode() {
77+
public StreamingOutput readSecurityJsonNode() {
7678
return readNodeAndAddToResponse("/security.json");
7779
}
7880

@@ -141,18 +143,19 @@ private String guessMime(byte firstByte) {
141143
}
142144

143145
/** Reads content of a znode */
144-
private ZooKeeperFileResponse readNodeAndAddToResponse(String zkPath) {
145-
final ZooKeeperFileResponse zkFileResponse =
146-
instantiateJerseyResponse(ZooKeeperFileResponse.class);
147-
146+
private StreamingOutput readNodeAndAddToResponse(String zkPath) {
148147
byte[] d = readPathFromZookeeper(zkPath);
149148
if (d == null || d.length == 0) {
150-
zkFileResponse.zkData = EMPTY;
151-
return zkFileResponse;
149+
d = new byte[0];
152150
}
153151

154-
zkFileResponse.output = new ContentStreamBase.ByteArrayStream(d, null, guessMime(d[0]));
155-
return zkFileResponse;
152+
final var bytesToWrite = d;
153+
return new StreamingOutput() {
154+
@Override
155+
public void write(OutputStream output) throws IOException, WebApplicationException {
156+
output.write(bytesToWrite);
157+
}
158+
};
156159
}
157160

158161
/** Reads a single node from zookeeper and return as byte array */

solr/core/src/test/org/apache/solr/handler/admin/ZookeeperReadAPITest.java

+47-46
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,18 @@
1717

1818
package org.apache.solr.handler.admin;
1919

20-
import static org.apache.solr.common.util.StrUtils.split;
21-
import static org.apache.solr.common.util.Utils.getObjectByPath;
2220
import static org.hamcrest.Matchers.containsInAnyOrder;
2321

24-
import com.fasterxml.jackson.databind.ObjectMapper;
2522
import java.net.URL;
23+
import java.nio.charset.StandardCharsets;
2624
import java.util.Map;
2725
import java.util.stream.Collectors;
28-
import org.apache.solr.client.api.model.ZooKeeperListChildrenResponse;
26+
import org.apache.commons.io.IOUtils;
2927
import org.apache.solr.client.api.model.ZooKeeperStat;
3028
import org.apache.solr.client.solrj.impl.HttpSolrClient;
29+
import org.apache.solr.client.solrj.request.ZookeeperReadApi;
3130
import org.apache.solr.cloud.SolrCloudTestCase;
3231
import org.apache.solr.common.SolrException;
33-
import org.apache.solr.common.util.Utils;
3432
import org.apache.zookeeper.CreateMode;
3533
import org.junit.After;
3634
import org.junit.Before;
@@ -69,20 +67,29 @@ public void tearDown() throws Exception {
6967
@Test
7068
public void testZkread() throws Exception {
7169
try (HttpSolrClient client = new HttpSolrClient.Builder(baseUrl.toString()).build()) {
72-
Object o =
73-
Utils.executeGET(client.getHttpClient(), basezk + "/security.json", Utils.JSONCONSUMER);
74-
assertNotNull(o);
75-
o = Utils.executeGET(client.getHttpClient(), basezkls + "/configs", Utils.JSONCONSUMER);
70+
final var securityJsonRequest = new ZookeeperReadApi.ReadNode("/security.json");
71+
final var securityJsonResponse = securityJsonRequest.process(client);
72+
assertEquals(200, securityJsonResponse.getHttpStatus());
73+
try (final var stream = securityJsonResponse.getResponseStream()) {
74+
final var securityJsonContent = IOUtils.toString(stream, StandardCharsets.UTF_8);
75+
assertNotNull(securityJsonContent);
76+
}
77+
78+
final var configListRequest = new ZookeeperReadApi.ListNodes("/configs");
79+
final var configListResponse = configListRequest.process(client).getParsed();
7680
assertEquals(
77-
"16",
78-
String.valueOf(getObjectByPath(o, true, split(":/configs:_default:dataLength", ':'))));
81+
16, configListResponse.unknownProperties().get("/configs").get("_default").dataLength);
7982
assertEquals(
80-
"16", String.valueOf(getObjectByPath(o, true, split(":/configs:conf:dataLength", ':'))));
81-
assertEquals("0", String.valueOf(getObjectByPath(o, true, split("/stat/version", '/'))));
82-
83-
o = Utils.executeGET(client.getHttpClient(), basezk + "/configs", Utils.JSONCONSUMER);
84-
assertTrue(((Map) o).containsKey("zkData"));
85-
assertEquals("empty", ((Map) o).get("zkData"));
83+
16, configListResponse.unknownProperties().get("/configs").get("conf").dataLength);
84+
assertEquals(0, configListResponse.stat.version);
85+
86+
final var configDataRequest = new ZookeeperReadApi.ReadNode("/configs");
87+
final var configDataResponse = configDataRequest.process(client);
88+
// /configs exists but has no data, so API returns '200 OK' with empty response body
89+
assertEquals(200, configDataResponse.getHttpStatus());
90+
try (final var stream = configDataResponse.getResponseStream()) {
91+
assertEquals("", IOUtils.toString(stream, StandardCharsets.UTF_8));
92+
}
8693

8794
byte[] bytes = new byte[1024 * 5];
8895
for (int i = 0; i < bytes.length; i++) {
@@ -92,17 +99,16 @@ public void testZkread() throws Exception {
9299
cluster
93100
.getZkClient()
94101
.create("/configs/_default/testdata", bytes, CreateMode.PERSISTENT, true);
95-
Utils.executeGET(
96-
client.getHttpClient(),
97-
basezk + "/configs/_default/testdata",
98-
is -> {
99-
byte[] newBytes = new byte[bytes.length];
100-
is.read(newBytes);
101-
for (int i = 0; i < newBytes.length; i++) {
102-
assertEquals(bytes[i], newBytes[i]);
103-
}
104-
return null;
105-
});
102+
103+
final var testDataRequest = new ZookeeperReadApi.ReadNode("/configs/_default/testdata");
104+
final var testDataResponse = testDataRequest.process(client);
105+
assertEquals(200, testDataResponse.getHttpStatus());
106+
try (final var stream = testDataResponse.getResponseStream()) {
107+
final var foundContents = stream.readAllBytes();
108+
for (int i = 0; i < foundContents.length; i++) {
109+
assertEquals(foundContents[i], bytes[i]);
110+
}
111+
}
106112
} finally {
107113
cluster.getZkClient().delete("/configs/_default/testdata", -1, true);
108114
}
@@ -112,42 +118,37 @@ public void testZkread() throws Exception {
112118
@Test
113119
public void testRequestingDataFromNonexistentNodeReturnsAnError() throws Exception {
114120
try (HttpSolrClient client = new HttpSolrClient.Builder(baseUrl.toString()).build()) {
115-
final SolrException expected =
121+
final var missingNodeReq = new ZookeeperReadApi.ReadNode("/configs/_default/nonexistentnode");
122+
final var missingNodeResponse = missingNodeReq.process(client);
123+
assertEquals(404, missingNodeResponse.getHttpStatus());
124+
125+
final var expected =
116126
expectThrows(
117-
SolrException.class,
118-
() -> {
119-
Utils.executeGET(
120-
client.getHttpClient(),
121-
basezk + "/configs/_default/nonexistentnode",
122-
Utils.JSONCONSUMER);
123-
});
127+
SolrException.class, () -> missingNodeResponse.getResponseStreamIfSuccessful());
124128
assertEquals(404, expected.code());
125129
}
126130
}
127131

128132
@Test
129133
public void testCanListChildNodes() throws Exception {
130134
try (HttpSolrClient client = new HttpSolrClient.Builder(baseUrl.toString()).build()) {
131-
final ZooKeeperListChildrenResponse response =
132-
Utils.executeGET(
133-
client.getHttpClient(),
134-
basezkls + "/configs/_default",
135-
is -> {
136-
return new ObjectMapper().readValue(is, ZooKeeperListChildrenResponse.class);
137-
});
135+
final var listDefaultFilesReq = new ZookeeperReadApi.ListNodes("/configs/_default");
136+
final var listDefaultFilesResponse = listDefaultFilesReq.process(client).getParsed();
138137

139138
// At the top level, the response contains a key with the value of the specified zkPath
140-
assertEquals(1, response.unknownProperties().size());
139+
assertEquals(1, listDefaultFilesResponse.unknownProperties().size());
141140
assertEquals(
142141
"/configs/_default",
143-
response.unknownProperties().keySet().stream().collect(Collectors.toList()).get(0));
142+
listDefaultFilesResponse.unknownProperties().keySet().stream()
143+
.collect(Collectors.toList())
144+
.get(0));
144145

145146
// Under the specified zkPath is a key for each child, with values being that stat for that
146147
// node.
147148
// The actual stat values vary a good bit so aren't very useful to assert on, so let's just
148149
// make sure all of the expected child nodes were found.
149150
final Map<String, ZooKeeperStat> childStatsByPath =
150-
response.unknownProperties().get("/configs/_default");
151+
listDefaultFilesResponse.unknownProperties().get("/configs/_default");
151152
assertEquals(6, childStatsByPath.size());
152153
assertThat(
153154
childStatsByPath.keySet(),

0 commit comments

Comments
 (0)