Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: option to retrieve original json body if parse exception occurred #886

Merged
merged 16 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
Expand Up @@ -88,6 +88,11 @@ public Function<List<String>, Boolean> onWarnings() {
return onWarnings;
}

@Override
public boolean keepResponseBodyOnException() {
return false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a new field here, that is initialized from the builder's keepResponseBodyOnException.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the default one, shouldn't it be false by default?

}

@Override
public Builder toBuilder() {
return new Builder(this);
Expand All @@ -111,6 +116,7 @@ public abstract static class AbstractBuilder<BuilderT extends AbstractBuilder<Bu
private HeaderMap headers;
private Map<String, String> parameters;
private Function<List<String>, Boolean> onWarnings;
private boolean keepResponseBodyOnException;

public AbstractBuilder() {
}
Expand All @@ -119,10 +125,17 @@ public AbstractBuilder(DefaultTransportOptions options) {
this.headers = new HeaderMap(options.headers);
this.parameters = copyOrNull(options.parameters);
this.onWarnings = options.onWarnings;
this.keepResponseBodyOnException = options.keepResponseBodyOnException();
}

protected abstract BuilderT self();

@Override
public BuilderT keepResponseBodyOnException(boolean value){
this.keepResponseBodyOnException = value;
return self();
}

@Override
public BuilderT addHeader(String name, String value) {
if (name.equalsIgnoreCase(HeaderMap.CLIENT_META)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@
import co.elastic.clients.transport.instrumentation.Instrumentation;
import co.elastic.clients.transport.instrumentation.NoopInstrumentation;
import co.elastic.clients.transport.instrumentation.OpenTelemetryForElasticsearch;
import co.elastic.clients.util.ByteArrayBinaryData;
import co.elastic.clients.util.LanguageRuntimeVersions;
import co.elastic.clients.util.ApiTypeHelper;
import co.elastic.clients.util.BinaryData;
import co.elastic.clients.util.ByteArrayBinaryData;
import co.elastic.clients.util.ContentType;
import co.elastic.clients.util.MissingRequiredPropertyException;
import co.elastic.clients.util.NoCopyByteArrayOutputStream;
Expand Down Expand Up @@ -377,6 +377,7 @@ private <ResponseT> ResponseT decodeTransportResponse(
) throws IOException {

if (endpoint instanceof JsonEndpoint) {

@SuppressWarnings("unchecked")
JsonEndpoint<?, ResponseT, ?> jsonEndpoint = (JsonEndpoint<?, ResponseT, ?>) endpoint;
// Successful response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public interface TransportOptions {

Function<List<String>, Boolean> onWarnings();

boolean keepResponseBodyOnException();
l-trotta marked this conversation as resolved.
Show resolved Hide resolved

Builder toBuilder();

default TransportOptions with(Consumer<Builder> fn) {
Expand All @@ -59,5 +61,7 @@ interface Builder extends ObjectBuilder<TransportOptions> {
Builder removeParameter(String name);

Builder onWarnings(Function<List<String>, Boolean> listener);

l-trotta marked this conversation as resolved.
Show resolved Hide resolved
Builder keepResponseBodyOnException(boolean value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import co.elastic.clients.transport.http.HeaderMap;
import co.elastic.clients.transport.http.TransportHttpClient;
import co.elastic.clients.util.BinaryData;
import co.elastic.clients.util.ByteArrayBinaryData;
import co.elastic.clients.util.NoCopyByteArrayOutputStream;
import org.apache.http.Header;
import org.apache.http.HeaderElement;
Expand All @@ -34,8 +35,10 @@
import org.elasticsearch.client.RestClient;

import javax.annotation.Nullable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.util.AbstractList;
Expand Down Expand Up @@ -85,10 +88,14 @@ public RestClientOptions createOptions(@Nullable TransportOptions options) {
}

@Override
public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) throws IOException {
public Response performRequest(String endpointId, @Nullable Node node, Request request,
TransportOptions options) throws IOException {
RestClientOptions rcOptions = RestClientOptions.of(options);
org.elasticsearch.client.Request restRequest = createRestRequest(request, rcOptions);
org.elasticsearch.client.Response restResponse = restClient.performRequest(restRequest);
if (options.keepResponseBodyOnException()) {
return new RepeatableBodyResponse(restResponse);
}
return new RestResponse(restResponse);
}

Expand All @@ -103,7 +110,7 @@ public CompletableFuture<Response> performRequestAsync(
try {
RestClientOptions rcOptions = RestClientOptions.of(options);
restRequest = createRestRequest(request, rcOptions);
} catch(Throwable thr) {
} catch (Throwable thr) {
// Terminate early
future.completeExceptionally(thr);
return future;
Expand All @@ -112,6 +119,9 @@ public CompletableFuture<Response> performRequestAsync(
future.cancellable = restClient.performRequestAsync(restRequest, new ResponseListener() {
@Override
public void onSuccess(org.elasticsearch.client.Response response) {
if (options.keepResponseBodyOnException()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved to ElasticsearchTransportBase, which will allow it to be independent of the actual HttpClient implementation, and also RepeatableBodyResponse which will then implement TransportHttpClient.Response.

future.complete(new RepeatableBodyResponse(response));
}
future.complete(new RestResponse(response));
}

Expand Down Expand Up @@ -166,7 +176,7 @@ private org.elasticsearch.client.Request createRestRequest(Request request, Rest
if (body != null) {
ContentType ct = null;
String ctStr;
if (( ctStr = requestHeaders.get(HeaderMap.CONTENT_TYPE)) != null) {
if ((ctStr = requestHeaders.get(HeaderMap.CONTENT_TYPE)) != null) {
ct = ContentTypeCache.computeIfAbsent(ctStr, ContentType::parse);
}
clientReq.setEntity(new MultiBufferEntity(body, ct));
Expand Down Expand Up @@ -241,6 +251,51 @@ public void close() throws IOException {
}
}

public class RepeatableBodyResponse extends RestResponse {

BinaryData repeatableBody;

RepeatableBodyResponse(org.elasticsearch.client.Response restResponse) {
super(restResponse);
}

@Nullable
@Override
public BinaryData body() throws IOException {
if(repeatableBody != null) {
return repeatableBody;
}
BinaryData body = super.body();
if (body != null) {
if(body.isRepeatable()){
repeatableBody = body;
}
else{
repeatableBody = new ByteArrayBinaryData(body);
}
}
return repeatableBody;
}

public String getOriginalBodyAsString() throws IOException {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is used only in tests. Let's not add in the public API some things that aren't required to use the library.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually wanted to provide this as a utility method to let users get the string directly

BinaryData body = body();

if (body != null) {
StringBuilder sb = new StringBuilder();
BufferedReader br = new BufferedReader(new InputStreamReader(body.asInputStream()));
String read;

while ((read = br.readLine()) != null) {
sb.append(read);
}
br.close();
return sb.toString();
}
return null;
}

}

private static class HttpEntityBinaryData implements BinaryData {
private final HttpEntity entity;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public class RestClientOptions implements TransportOptions {

private final RequestOptions options;

boolean keepResponseBodyOnException;

@VisibleForTesting
static final String CLIENT_META_VALUE = getClientMeta();
@VisibleForTesting
Expand All @@ -63,7 +65,8 @@ static RestClientOptions of(@Nullable TransportOptions options) {
return builder.build();
}

public RestClientOptions(RequestOptions options) {
public RestClientOptions(RequestOptions options, boolean keepResponseBodyOnException) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should keep the existing constructor without the additional parameter to avoid a breaking change (and default the additional parameter to false).

Also, to be future proof if we add more options in the features, what about changing this constructor to RestClientOptions(RequestOptions.Builder builder) so that we can add more properties without impacting the constructor's signature?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the future proof constructor will make more sense when we'll have control over RequestOptions in the low level client!

this.keepResponseBodyOnException = keepResponseBodyOnException;
this.options = addBuiltinHeaders(options.toBuilder()).build();
}

Expand Down Expand Up @@ -99,6 +102,11 @@ public Function<List<String>, Boolean> onWarnings() {
return warnings -> options.getWarningsHandler().warningsShouldFailRequest(warnings);
}

@Override
public boolean keepResponseBodyOnException() {
return this.keepResponseBodyOnException;
}

@Override
public Builder toBuilder() {
return new Builder(options.toBuilder());
Expand All @@ -108,6 +116,8 @@ public static class Builder implements TransportOptions.Builder {

private RequestOptions.Builder builder;

private boolean keepResponseBodyOnException;

public Builder(RequestOptions.Builder builder) {
this.builder = builder;
}
Expand Down Expand Up @@ -181,14 +191,20 @@ public TransportOptions.Builder onWarnings(Function<List<String>, Boolean> liste
return this;
}

@Override
public TransportOptions.Builder keepResponseBodyOnException(boolean value) {
this.keepResponseBodyOnException = value;
return this;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it empty? It should set a boolean flag in the builder, and setRetrieveOriginalJsonResponseOnException on RestClientOptions should be removed to keep it immutable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it cannot set a boolean flag in the builder, because the builder is in the RestClient code

}

@Override
public RestClientOptions build() {
return new RestClientOptions(addBuiltinHeaders(builder).build());
return new RestClientOptions(addBuiltinHeaders(builder).build(), keepResponseBodyOnException);
}
}

static RestClientOptions initialOptions() {
return new RestClientOptions(SafeResponseConsumer.DEFAULT_REQUEST_OPTIONS);
return new RestClientOptions(SafeResponseConsumer.DEFAULT_REQUEST_OPTIONS, false);
}

private static RequestOptions.Builder addBuiltinHeaders(RequestOptions.Builder builder) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public Function<List<String>, Boolean> onWarnings() {
return null;
}

@Override
public boolean keepResponseBodyOnException() {
return false;
}

@Override
public Builder toBuilder() {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientHttpClient;
import co.elastic.clients.transport.rest_client.RestClientOptions;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import com.sun.net.httpserver.HttpServer;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.Response;
import org.elasticsearch.client.RestClient;
import org.junit.jupiter.api.Assertions;
Expand All @@ -33,12 +36,16 @@
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Collections;

import static co.elastic.clients.util.ContentType.APPLICATION_JSON;

public class TransportTest extends Assertions {

@Test
public void testXMLResponse() throws Exception {
HttpServer httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
HttpServer httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(),
0), 0);

httpServer.createContext("/_cat/indices", exchange -> {
exchange.sendResponseHeaders(401, 0);
Expand All @@ -56,7 +63,8 @@ public void testXMLResponse() throws Exception {
.builder(new HttpHost(address.getHostString(), address.getPort(), "http"))
.build();

ElasticsearchClient esClient = new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper()));
ElasticsearchClient esClient = new ElasticsearchClient(new RestClientTransport(restClient,
new JacksonJsonpMapper()));

TransportException ex = Assertions.assertThrows(
TransportException.class,
Expand All @@ -69,7 +77,67 @@ public void testXMLResponse() throws Exception {
assertEquals("es/cat.indices", ex.endpointId());

// Original response is transport-dependent
Response restClientResponse = (Response)ex.response().originalResponse();
Response restClientResponse = (Response) ex.response().originalResponse();
assertEquals(401, restClientResponse.getStatusLine().getStatusCode());
}


@Test
public void testOriginalJsonBodyRetrievalException() throws Exception {
HttpServer httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(),
0), 0);

httpServer.createContext("/_cat/indices", exchange -> {
exchange.getResponseHeaders().put("Content-Type", Collections.singletonList(APPLICATION_JSON));
exchange.getResponseHeaders().put("X-Elastic-Product", Collections.singletonList("Elasticsearch"
));
exchange.sendResponseHeaders(200, 0);
OutputStream out = exchange.getResponseBody();
out.write(
"definitely not json".getBytes(StandardCharsets.UTF_8)
);
out.close();
});

httpServer.start();
InetSocketAddress address = httpServer.getAddress();

RestClient restClient = RestClient
.builder(new HttpHost(address.getHostString(), address.getPort(), "http"))
.build();

// no transport options, should throw TransportException, but original body cannot be retrieved
ElasticsearchClient esClient = new ElasticsearchClient(new RestClientTransport(restClient,
new JacksonJsonpMapper()));

TransportException ex = Assertions.assertThrows(
TransportException.class,
() -> esClient.cat().indices()
);

assertEquals(200, ex.statusCode());
assertNotEquals(RestClientHttpClient.RepeatableBodyResponse.class, ex.response().getClass());

// setting transport option
RestClientOptions options = new RestClientOptions(RequestOptions.DEFAULT, true);

ElasticsearchTransport transport = new RestClientTransport(
restClient, new JacksonJsonpMapper(), options);

ElasticsearchClient esClientOptions = new ElasticsearchClient(transport);

ex = Assertions.assertThrows(
TransportException.class,
() -> esClientOptions.cat().indices()
);

httpServer.stop(0);

assertEquals(200, ex.statusCode());
assertEquals(RestClientHttpClient.RepeatableBodyResponse.class, ex.response().getClass());

try (RestClientHttpClient.RepeatableBodyResponse repeatableResponse = (RestClientHttpClient.RepeatableBodyResponse) ex.response()){
assertEquals("definitely not json",repeatableResponse.getOriginalBodyAsString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ void testRequestOptionsOverridingBuiltin() throws Exception {
new HttpHost(httpServer.getAddress().getHostString(), httpServer.getAddress().getPort(), "http")
).build();

ElasticsearchTransport transport = newRestClientTransport(llrc, new SimpleJsonpMapper(), new RestClientOptions(options));
ElasticsearchTransport transport = newRestClientTransport(llrc, new SimpleJsonpMapper(), new RestClientOptions(options,false));
ElasticsearchClient esClient = new ElasticsearchClient(transport);
// Should not override client meta
String id = checkHeaders(esClient);
Expand Down
Loading