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 3 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 retrieveOriginalJsonResponseOnException() {
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 retrieveOriginalJsonResponseOnException;

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.retrieveOriginalJsonResponseOnException = options.retrieveOriginalJsonResponseOnException();
}

protected abstract BuilderT self();

@Override
public BuilderT retrieveOriginalJsonResponseOnException(boolean value){
this.retrieveOriginalJsonResponseOnException = 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 @@ -45,6 +45,8 @@
import jakarta.json.stream.JsonParser;

import javax.annotation.Nullable;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
Expand Down Expand Up @@ -377,28 +379,42 @@ private <ResponseT> ResponseT decodeTransportResponse(
) throws IOException {

if (endpoint instanceof JsonEndpoint) {

// Expecting a body
if (entity == null) {
throw new TransportException(
clientResp,
"Expecting a response body, but none was sent",
endpoint.id()
);
}
InputStream content = entity.asInputStream();
InputStream contentForException = null;

// if the option to print the original body has been set, the body has to be
// copied first to another stream to be read again by the exception class
if(options().retrieveOriginalJsonResponseOnException()) {
Copy link
Member

Choose a reason for hiding this comment

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

We already have something similar for error responses:

// We may have to replay it.
if (!entity.isRepeatable()) {
entity = new ByteArrayBinaryData(entity);
}

We could do the same here. Or even factorize it in a new method BinaryData ensureRepeatable() in BinaryData

try(ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
entity.writeTo(baos);
content = new ByteArrayInputStream(baos.toByteArray());
contentForException = new ByteArrayInputStream(baos.toByteArray());
}
}

@SuppressWarnings("unchecked")
JsonEndpoint<?, ResponseT, ?> jsonEndpoint = (JsonEndpoint<?, ResponseT, ?>) endpoint;
// Successful response
ResponseT response = null;
JsonpDeserializer<ResponseT> responseParser = jsonEndpoint.responseDeserializer();
if (responseParser != null) {
// Expecting a body
if (entity == null) {
throw new TransportException(
clientResp,
"Expecting a response body, but none was sent",
endpoint.id()
);
}
checkJsonContentType(entity.contentType(), clientResp, endpoint);
try (
InputStream content = entity.asInputStream();
JsonParser parser = mapper.jsonProvider().createParser(content)
) {
response = responseParser.deserialize(parser, mapper);
} catch (Exception e) {
throw new TransportException(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

custom exception to avoid changing too many lines around

throw new TransportBodyResponseException(
contentForException,
clientResp,
"Failed to decode response",
endpoint.id(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package co.elastic.clients.transport;

import co.elastic.clients.transport.http.TransportHttpClient;

import javax.annotation.Nullable;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;

public class TransportBodyResponseException extends TransportException {

private String originalBody;

public TransportBodyResponseException(InputStream originalBody,TransportHttpClient.Response response, String message, String endpointId,
Throwable cause) {
super(response, message, endpointId, cause);
try {
if (originalBody != null) {
StringBuilder sb = new StringBuilder();
BufferedReader br = new BufferedReader(new InputStreamReader(originalBody));
String read;

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

br.close();
this.originalBody = sb.toString();
// Closing original body input stream
originalBody.close();
}

// Make sure the response is closed to free up resources.
response.close();
} catch (Exception e) {
this.addSuppressed(e);
}
}

/**
* The original response body, before json deserialization.
*/
@Nullable
public String originalBody() {
Copy link
Member

Choose a reason for hiding this comment

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

Having a different kind of exception depending on the "capture body" flag is a bit confusing.

Another approach would be to add a new implementation of TransportHttpClient.Response, e.g. RepeatableBodyResponse that would wrap another response to ensure its body (if any) is repeatable, and delegate everything else to the wrapped response. This could even replace the entity = new ByteArrayBinaryData(entity) suggestion above.

return originalBody;
}
}
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 retrieveOriginalJsonResponseOnException();

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 retrieveOriginalJsonResponseOnException(boolean value);
}
}
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 retrieveOriginalJsonResponseOnException;

@VisibleForTesting
static final String CLIENT_META_VALUE = getClientMeta();
@VisibleForTesting
Expand Down Expand Up @@ -99,6 +101,15 @@ public Function<List<String>, Boolean> onWarnings() {
return warnings -> options.getWarningsHandler().warningsShouldFailRequest(warnings);
}

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

public void setRetrieveOriginalJsonResponseOnException(boolean retrieveOriginalJsonResponseOnException) {
this.retrieveOriginalJsonResponseOnException = retrieveOriginalJsonResponseOnException;
}

@Override
public Builder toBuilder() {
return new Builder(options.toBuilder());
Expand Down Expand Up @@ -181,6 +192,11 @@ public TransportOptions.Builder onWarnings(Function<List<String>, Boolean> liste
return this;
}

@Override
public TransportOptions.Builder retrieveOriginalJsonResponseOnException(boolean 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());
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 retrieveOriginalJsonResponseOnException() {
Copy link
Member

Choose a reason for hiding this comment

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

Rename it to something shorter like keepResponseBodyOnException()? With a javadoc saying something like "Should the response body always be buffered and made available in TransportException.response.body()?"

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,11 @@

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
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,6 +35,9 @@
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 {

Expand Down Expand Up @@ -72,4 +77,58 @@ public void testXMLResponse() throws Exception {
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 TransportBodyResponseException, but with an empty originalBody
ElasticsearchClient esClient = new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper()));

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

assertEquals(200, ex.statusCode());
assertEquals(null, ex.originalBody());

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

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

ElasticsearchClient esClientOptions = new ElasticsearchClient(transport);

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

httpServer.stop(0);

assertEquals(200, ex.statusCode());
assertEquals( "definitely not json", ex.originalBody());
}
}
Loading