Skip to content

Commit 0dbe2d7

Browse files
authored
Improve handling of empty response (elastic#126393)
Today `ActionResponse$Empty` implements `ToXContentObject`, but yields no bytes of content when serialized which creates an invalid JSON response. This commit removes the bogus interface and adjusts the affected REST APIs to send a `text/plain` response instead. Backport of elastic#125562 to `8.x`
1 parent f1bdf01 commit 0dbe2d7

File tree

32 files changed

+248
-75
lines changed

32 files changed

+248
-75
lines changed

docs/changelog/125562.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 125562
2+
summary: Improve handling of empty response
3+
area: Infra/REST API
4+
type: bug
5+
issues:
6+
- 57639

modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/RestMultiSearchTemplateActionTests.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,24 @@
1717
import org.elasticsearch.test.rest.RestActionTestCase;
1818
import org.elasticsearch.xcontent.XContentType;
1919
import org.junit.Before;
20-
import org.mockito.Mockito;
2120

2221
import java.nio.charset.StandardCharsets;
2322
import java.util.Collections;
2423
import java.util.List;
2524
import java.util.Map;
2625

26+
import static org.elasticsearch.rest.RestResponseUtils.setUpXContentMock;
27+
import static org.mockito.Mockito.mock;
28+
2729
public final class RestMultiSearchTemplateActionTests extends RestActionTestCase {
2830
final List<String> contentTypeHeader = Collections.singletonList(compatibleMediaType(XContentType.VND_JSON, RestApiVersion.V_7));
2931

3032
@Before
3133
public void setUpAction() {
3234
controller().registerHandler(new RestMultiSearchTemplateAction(Settings.EMPTY));
3335
// todo how to workaround this? we get AssertionError without this
34-
verifyingClient.setExecuteVerifier((actionType, request) -> Mockito.mock(MultiSearchTemplateResponse.class));
35-
verifyingClient.setExecuteLocallyVerifier((actionType, request) -> Mockito.mock(MultiSearchTemplateResponse.class));
36+
verifyingClient.setExecuteVerifier((actionType, request) -> setUpXContentMock(mock(MultiSearchTemplateResponse.class)));
37+
verifyingClient.setExecuteLocallyVerifier((actionType, request) -> setUpXContentMock(mock(MultiSearchTemplateResponse.class)));
3638
}
3739

3840
public void testTypeInPath() {

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.desired_balance/10_basic.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ setup:
180180
- requires:
181181
cluster_features: ["gte_v8.8.0"]
182182
reason: "reset API added in in 8.8.0"
183+
test_runner_features: [ capabilities ]
184+
capabilities:
185+
- method: DELETE
186+
path: /_internal/desired_balance
187+
capabilities: [ plain_text_empty_response ]
183188

184189
- do:
185190
_internal.delete_desired_balance: { }

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.desired_nodes/10_basic.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ setup:
33
- requires:
44
cluster_features: ["gte_v8.13.0"]
55
reason: "API added in in 8.1.0 but modified in 8.13 (node_version field removed)"
6+
test_runner_features: [ capabilities ]
7+
capabilities:
8+
- method: DELETE
9+
path: /_internal/desired_nodes
10+
capabilities: [ plain_text_empty_response ]
611
---
712
teardown:
813
- do:

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.desired_nodes/11_old_format.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ setup:
66
- requires:
77
cluster_features: ["gte_v8.3.0"]
88
reason: "API added in in 8.1.0 but modified in 8.3"
9+
test_runner_features: [ capabilities ]
10+
capabilities:
11+
- method: DELETE
12+
path: /_internal/desired_nodes
13+
capabilities: [ plain_text_empty_response ]
914
---
1015
teardown:
1116
- do:

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.desired_nodes/20_dry_run.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ setup:
33
- requires:
44
cluster_features: ["gte_v8.4.0"]
55
reason: "Support for the dry run option was added in in 8.4.0"
6+
test_runner_features: [ capabilities ]
7+
capabilities:
8+
- method: DELETE
9+
path: /_internal/desired_nodes
10+
capabilities: [ plain_text_empty_response ]
611
---
712
teardown:
813
- do:

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/cluster.voting_config_exclusions/10_basic.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
setup:
2+
- requires:
3+
test_runner_features: [ capabilities ]
4+
capabilities:
5+
- method: POST
6+
path: /_cluster/voting_config_exclusions
7+
capabilities: [ plain_text_empty_response ]
8+
- method: DELETE
9+
path: /_cluster/voting_config_exclusions
10+
capabilities: [ plain_text_empty_response ]
11+
reason: needs these capabilities
12+
13+
---
114
teardown:
215
- do:
316
cluster.delete_voting_config_exclusions: {}

server/src/internalClusterTest/java/org/elasticsearch/cluster/coordination/VotingConfigurationIT.java

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,24 @@
99
package org.elasticsearch.cluster.coordination;
1010

1111
import org.elasticsearch.ElasticsearchException;
12-
import org.elasticsearch.action.admin.cluster.configuration.AddVotingConfigExclusionsRequest;
13-
import org.elasticsearch.action.admin.cluster.configuration.TransportAddVotingConfigExclusionsAction;
12+
import org.elasticsearch.client.Request;
13+
import org.elasticsearch.client.Response;
1414
import org.elasticsearch.cluster.ClusterState;
1515
import org.elasticsearch.cluster.node.DiscoveryNode;
16+
import org.elasticsearch.cluster.service.ClusterService;
1617
import org.elasticsearch.common.Priority;
1718
import org.elasticsearch.plugins.Plugin;
1819
import org.elasticsearch.test.ESIntegTestCase;
1920
import org.elasticsearch.test.transport.MockTransportService;
2021
import org.elasticsearch.transport.TransportService;
2122

23+
import java.io.IOException;
2224
import java.util.Collection;
2325
import java.util.Collections;
2426
import java.util.List;
2527
import java.util.Set;
26-
import java.util.concurrent.ExecutionException;
2728

29+
import static org.hamcrest.Matchers.empty;
2830
import static org.hamcrest.Matchers.equalTo;
2931
import static org.hamcrest.Matchers.hasItem;
3032
import static org.hamcrest.Matchers.hasSize;
@@ -38,18 +40,39 @@ protected Collection<Class<? extends Plugin>> nodePlugins() {
3840
return Collections.singletonList(MockTransportService.TestPlugin.class);
3941
}
4042

41-
public void testAbdicateAfterVotingConfigExclusionAdded() throws ExecutionException, InterruptedException {
43+
@Override
44+
protected boolean addMockHttpTransport() {
45+
return false; // enable HTTP
46+
}
47+
48+
public void testAbdicateAfterVotingConfigExclusionAdded() throws IOException {
4249
internalCluster().setBootstrapMasterNodeIndex(0);
4350
internalCluster().startNodes(2);
4451
final String originalMaster = internalCluster().getMasterName();
52+
final var restClient = getRestClient();
4553

4654
logger.info("--> excluding master node {}", originalMaster);
47-
client().execute(
48-
TransportAddVotingConfigExclusionsAction.TYPE,
49-
new AddVotingConfigExclusionsRequest(TEST_REQUEST_TIMEOUT, originalMaster)
50-
).get();
55+
final var excludeRequest = new Request("POST", "/_cluster/voting_config_exclusions");
56+
excludeRequest.addParameter("node_names", originalMaster);
57+
assertEmptyResponse(restClient.performRequest(excludeRequest));
58+
5159
clusterAdmin().prepareHealth(TEST_REQUEST_TIMEOUT).setWaitForEvents(Priority.LANGUID).get();
5260
assertNotEquals(originalMaster, internalCluster().getMasterName());
61+
62+
final var clearRequest = new Request("DELETE", "/_cluster/voting_config_exclusions");
63+
clearRequest.addParameter("wait_for_removal", "false");
64+
assertEmptyResponse(restClient.performRequest(clearRequest));
65+
66+
assertThat(
67+
internalCluster().getInstance(ClusterService.class).state().metadata().coordinationMetadata().getVotingConfigExclusions(),
68+
empty()
69+
);
70+
}
71+
72+
private void assertEmptyResponse(Response response) throws IOException {
73+
assertEquals("text/plain; charset=UTF-8", response.getHeader("content-type"));
74+
assertEquals(0, response.getEntity().getContentLength());
75+
assertEquals(0, response.getEntity().getContent().readAllBytes().length);
5376
}
5477

5578
public void testElectsNodeNotInVotingConfiguration() throws Exception {

server/src/main/java/org/elasticsearch/action/ActionResponse.java

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,10 @@
1111

1212
import org.elasticsearch.common.io.stream.StreamInput;
1313
import org.elasticsearch.common.io.stream.StreamOutput;
14+
import org.elasticsearch.rest.action.EmptyResponseListener;
1415
import org.elasticsearch.transport.TransportResponse;
15-
import org.elasticsearch.xcontent.ToXContentObject;
16-
import org.elasticsearch.xcontent.XContentBuilder;
17-
18-
import java.io.IOException;
16+
import org.elasticsearch.xcontent.ToXContent;
17+
import org.elasticsearch.xcontent.XContent;
1918

2019
/**
2120
* Base class for responses to action requests.
@@ -24,24 +23,25 @@ public abstract class ActionResponse extends TransportResponse {
2423

2524
public ActionResponse() {}
2625

27-
public ActionResponse(StreamInput in) throws IOException {
28-
super(in);
29-
}
26+
public ActionResponse(StreamInput in) {}
27+
28+
/**
29+
* A response with no payload. This is deliberately not an implementation of {@link ToXContent} or similar because an empty response
30+
* has no valid {@link XContent} representation. Use {@link EmptyResponseListener} to convert this to a valid (plain-text) REST
31+
* response instead.
32+
*/
33+
public static final class Empty extends ActionResponse {
34+
35+
private Empty() { /* singleton */ }
3036

31-
public static final class Empty extends ActionResponse implements ToXContentObject {
3237
public static final ActionResponse.Empty INSTANCE = new ActionResponse.Empty();
3338

3439
@Override
3540
public String toString() {
36-
return "EmptyActionResponse{}";
41+
return "ActionResponse.Empty{}";
3742
}
3843

3944
@Override
4045
public void writeTo(StreamOutput out) {}
41-
42-
@Override
43-
public XContentBuilder toXContent(final XContentBuilder builder, final Params params) {
44-
return builder;
45-
}
4646
}
4747
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.rest.action;
11+
12+
import org.elasticsearch.action.ActionResponse;
13+
import org.elasticsearch.common.bytes.BytesArray;
14+
import org.elasticsearch.rest.RestChannel;
15+
import org.elasticsearch.rest.RestResponse;
16+
import org.elasticsearch.rest.RestStatus;
17+
18+
/**
19+
* A listener which converts a successful {@link ActionResponse.Empty} action response into a {@code 200 OK} REST response with empty body.
20+
*/
21+
public final class EmptyResponseListener extends RestResponseListener<ActionResponse.Empty> {
22+
public EmptyResponseListener(RestChannel channel) {
23+
super(channel);
24+
}
25+
26+
@Override
27+
public RestResponse buildResponse(ActionResponse.Empty ignored) throws Exception {
28+
// Content-type header is not required for an empty body but some clients may expect it; the empty body is a valid text/plain entity
29+
// so we use that here.
30+
return new RestResponse(RestStatus.OK, RestResponse.TEXT_CONTENT_TYPE, BytesArray.EMPTY);
31+
}
32+
33+
/**
34+
* Capability name for APIs that previously would return an invalid zero-byte {@code application/json} response so that the YAML test
35+
* runner can avoid those APIs.
36+
*/
37+
public static final String PLAIN_TEXT_EMPTY_RESPONSE_CAPABILITY_NAME = "plain_text_empty_response";
38+
}

0 commit comments

Comments
 (0)