Skip to content

Commit

Permalink
feat: improve error response messages
Browse files Browse the repository at this point in the history
Add ErrorTransformInterceptor.
Modify CloudantBaseService to apply interceptor.
New tests in ErrorTransformInterceptorTest and CloudantErrorInterceptorTest.

Co-authored-by: Rich Ellis <[email protected]>
  • Loading branch information
emlaver and ricellis committed Nov 1, 2024
1 parent 3e964d4 commit 1369e17
Show file tree
Hide file tree
Showing 4 changed files with 650 additions and 1 deletion.
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

0 comments on commit 1369e17

Please sign in to comment.