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

feat: improve error response messages #614

Merged
merged 1 commit into from
Nov 1, 2024
Merged
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,168 @@
package com.ibm.cloud.cloudant.v1;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;

import java.util.function.Consumer;
import java.util.function.Function;
import org.testng.annotations.Test;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.ibm.cloud.cloudant.v1.model.GetDocumentOptions;
import com.ibm.cloud.cloudant.v1.model.HeadDocumentOptions;
import com.ibm.cloud.sdk.core.security.NoAuthAuthenticator;
import com.ibm.cloud.sdk.core.service.exception.ServiceResponseException;
import com.ibm.cloud.sdk.core.util.GsonSingleton;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;

public class CloudantErrorInterceptorTest {

private static final String REQ_ID = "338da230c5";
private static final String EXPECTED_ERROR = "test_error_name";
private static final String EXPECTED_REASON = "test reason";
private static final String MOCK_RESPONSE_BODY = "{\"error\":\"" + EXPECTED_ERROR+ "\",\"reason\":\"" + EXPECTED_REASON + "\"}";

public void runTest(
MockResponse mockResponse,
Function<Cloudant, ?> serviceCall,
Consumer<JsonObject> bodyAssertion
) throws Exception {
try(MockWebServer server = new MockWebServer()) {
server.start();
server.enqueue(mockResponse);
Cloudant cloudantService = new Cloudant("test", new NoAuthAuthenticator());
cloudantService.setServiceUrl(server.url("/test/foo").toString());
try {
serviceCall.apply(cloudantService);
} catch(ServiceResponseException sre) {
JsonObject errorBody = null;
String body = sre.getResponseBody();
if (body != null) {
errorBody = GsonSingleton.getGson().fromJson(body, JsonObject.class);
}
bodyAssertion.accept(errorBody);
}
}
}

class AssertAugment implements Consumer<JsonObject> {

@Override
public void accept(JsonObject actualErrorBody) {
assertNotNull(actualErrorBody, "There should be a body.");
assertTrue(actualErrorBody.has("errors"));
JsonArray errors = actualErrorBody.getAsJsonArray("errors");
assertEquals(1, errors.size());
JsonObject error = errors.get(0).getAsJsonObject();
assertTrue(error.has("code"));
assertTrue(error.has("message"));
assertEquals(error.get("code").getAsString(), EXPECTED_ERROR);
assertEquals(error.get("message").getAsString(), EXPECTED_ERROR + ": " + EXPECTED_REASON);
assertTrue(actualErrorBody.has("trace"));
assertEquals(actualErrorBody.get("trace").getAsString(), REQ_ID);
}

}

class AssertNoAugment implements Consumer<JsonObject> {

@Override
public void accept(JsonObject actualErrorBody) {
assertNotNull(actualErrorBody, "There should be a body.");
assertFalse(actualErrorBody.has("errors"));
assertFalse(actualErrorBody.has("trace"));
}

}

class AssertNoBody implements Consumer<JsonObject> {

@Override
public void accept(JsonObject actualErrorBody) {
assertNull(actualErrorBody, "There should be no body.");
}

}

@Test
public void testDocumentNoError() throws Throwable {
// Register a mock response
MockResponse mockResponse = new MockResponse()
.setHeader("Content-type", "application/json")
.setHeader("x-couch-request-id", REQ_ID)
.setResponseCode(200)
.setBody("{\"_id\": \"foo\"}");

GetDocumentOptions opts = new GetDocumentOptions.Builder()
.db("test")
.docId("foo")
.build();

runTest(mockResponse,
c -> c.getDocument(opts).execute(),
new AssertNoAugment());
}

@Test
public void testDocumentError() throws Throwable {
// Register a mock response
MockResponse mockResponse = new MockResponse()
.setHeader("Content-type", "application/json")
.setHeader("x-couch-request-id", REQ_ID)
.setResponseCode(444)
.setBody(MOCK_RESPONSE_BODY);

GetDocumentOptions opts = new GetDocumentOptions.Builder()
.db("test")
.docId("foo")
.build();

runTest(mockResponse,
c -> c.getDocument(opts).execute(),
new AssertAugment());
}

@Test
public void testDocumentAsStreamError() throws Throwable {
// Register a mock response

MockResponse mockResponse = new MockResponse()
.setHeader("Content-type", "application/json")
.setHeader("x-couch-request-id", REQ_ID)
.setResponseCode(444)
.setChunkedBody(MOCK_RESPONSE_BODY, 1);

GetDocumentOptions opts = new GetDocumentOptions.Builder()
.db("test")
.docId("foo")
.build();

runTest(mockResponse,
c -> c.getDocumentAsStream(opts).execute(),
new AssertAugment());
}


@Test
public void testDocumentHeadError() throws Throwable {
// Register a mock response
MockResponse mockResponse = new MockResponse()
.setHeader("Content-type", "application/json")
.setHeader("x-couch-request-id", REQ_ID)
.setResponseCode(444);

HeadDocumentOptions opts = new HeadDocumentOptions.Builder()
.db("test")
.docId("foo")
.build();

runTest(mockResponse,
c -> c.headDocument(opts).execute(),
new AssertNoBody());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.ibm.cloud.sdk.core.service.BaseService;
import com.ibm.cloud.cloudant.security.CouchDbSessionAuthenticator;
import okhttp3.HttpUrl;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import java.util.Arrays;
Expand All @@ -39,6 +40,8 @@
*/
public abstract class CloudantBaseService extends BaseService {

private static Interceptor errorInterceptor = new ErrorTransformInterceptor();

private int serviceUrlPathSegmentSize = 0;

public CloudantBaseService(String serviceName, Authenticator authenticator) {
Expand Down Expand Up @@ -80,7 +83,14 @@ public void setServiceUrl(String serviceUrl) {

@Override
public void setClient(OkHttpClient client) {
super.setClient(client);
if (!client.interceptors().contains(errorInterceptor)) {
OkHttpClient.Builder builder = client.newBuilder();
builder.addInterceptor(errorInterceptor);
OkHttpClient newClient = builder.build();
super.setClient(newClient);
} else {
super.setClient(client);
}
customizeAuthenticator(a -> a.setClient(this.getClient()));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* © Copyright IBM Corporation 2024. All Rights Reserved.
*
* Licensed 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 com.ibm.cloud.cloudant.internal;

import java.io.IOException;
import java.util.Optional;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.ibm.cloud.sdk.core.util.GsonSingleton;

import okhttp3.Interceptor;
import okhttp3.Response;
import okhttp3.ResponseBody;

public class ErrorTransformInterceptor implements Interceptor {

@Override
public Response intercept(Chain chain) throws IOException {
// Don't modify the request, but get the response
Response response = chain.proceed(chain.request());
if (!response.isSuccessful() // skip successful responses
&& response.body() != null // skip cases with no body
&& response.body().contentType() != null // skip cases with no content type
&& "application".equals(response.body().contentType().type())
&& "json".equals(response.body().contentType().subtype()) // we only want to work with application/json
) {
String errorResponse = response.body().string();
JsonObject errorBody = null;
try {
errorBody = GsonSingleton.getGson().fromJson(errorResponse, JsonObject.class);
if (errorBody != null) {
// Don't augment if there is already a trace present
if (!errorBody.has("trace")) {
if (!errorBody.has("errors")) {
String error = Optional.ofNullable(errorBody.get("error")).map(JsonElement::getAsString).orElse(null);
String reason = Optional.ofNullable(errorBody.get("reason")).map(JsonElement::getAsString).orElse(null);
if (error != null) {
// Augment with errors array model
JsonObject errorModel = new JsonObject();
errorModel.addProperty("code", error);
StringBuilder messageBuilder = new StringBuilder(error);
if (reason != null && !reason.isEmpty()) {
messageBuilder.append(": ");
messageBuilder.append(reason);
}
errorModel.addProperty("message", messageBuilder.toString());
JsonArray errors = new JsonArray(1);
errors.add(errorModel);
errorBody.getAsJsonObject().add("errors", errors);
// Propose the new augmented response, it may be augmented again by trace
errorResponse = errorBody.toString();
}
}
if (errorBody.has("errors")) {
String trace = response.header("x-couch-request-id");
if (trace != null && !trace.isEmpty()) {
// Augment with trace
errorBody.addProperty("trace", trace);
// Propose the new augmented response
errorResponse = errorBody.toString();
}
}
}
}
} catch (JsonParseException e) {
// If response body is malformed, just return the original response
}
// Make a new body to return, either using the original repsonse or
// the modified one.
response = response
.newBuilder()
.body(ResponseBody.create(errorResponse,
response.body().contentType()))
.build();
}
return response;
}
}
Loading