diff --git a/DriveBackup/src/main/java/ratismal/drivebackup/uploaders/onedrive/GraphApiErrorException.java b/DriveBackup/src/main/java/ratismal/drivebackup/uploaders/onedrive/GraphApiErrorException.java index 80e56c3a..6dcb0707 100644 --- a/DriveBackup/src/main/java/ratismal/drivebackup/uploaders/onedrive/GraphApiErrorException.java +++ b/DriveBackup/src/main/java/ratismal/drivebackup/uploaders/onedrive/GraphApiErrorException.java @@ -1,16 +1,12 @@ package ratismal.drivebackup.uploaders.onedrive; -import okhttp3.Response; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.json.JSONArray; import org.json.JSONObject; import org.json.JSONException; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; +import static ratismal.drivebackup.util.JsonUtil.optJsonObjectIgnoreCase; +import static ratismal.drivebackup.util.JsonUtil.optStringIgnoreCase; /** * an exception representing a microsoft graph api error @@ -18,97 +14,76 @@ public class GraphApiErrorException extends Exception { private static final String ERROR_OBJ_KEY = "error"; private static final String CODE_STR_KEY = "code"; - private static final String INNERERROR_OBJ_KEY = "innererror"; private static final String MESSAGE_STR_KEY = "message"; - private static final String DETAILS_ARR_KEY = "details"; - /** status code of the response or -1 if not available */ + /** status code of the response */ public final int statusCode; /** an error code string for the error that occurred */ - public final String errorCode; + public final @NotNull String errorCode; /** a developer ready message about the error that occurred. this shouldn't be displayed to the user directly */ - public final String errorMessage; - /** optional list of additional error objects that might be more specific than the top-level error */ - public final List innerErrors; - /** - * optional list of additional error objects that might provide a breakdown of multiple errors encountered - * while processing the request - */ - public final List details; - - /** - * create the exception from a response - * - * @param response to parse error from its body - * @throws IOException if the body string could not be loaded - * @throws NullPointerException if the body could not be loaded - * @throws JSONException if the body does not contain the expected json values - */ - public GraphApiErrorException(@NotNull Response response) throws IOException { - this(response.code(), new JSONObject(response.body().string()).getJSONObject(ERROR_OBJ_KEY)); - } + public final @NotNull String errorMessage; + /** the full error object */ + public final @Nullable JSONObject errorObject; /** * create the exception from a status code and response body * - * @param statusCode of the response - * @param responseBody of the response + * @param statusCode of the response + * @param jsonResponse string of the response body * @throws JSONException if the body does not contain the expected json values */ - public GraphApiErrorException(int statusCode, @NotNull String responseBody) { - this(statusCode, new JSONObject(responseBody).getJSONObject(ERROR_OBJ_KEY)); + public GraphApiErrorException(int statusCode, @NotNull String jsonResponse) { + this(statusCode, new ParsedError(jsonResponse)); } - private static List parseInnerErrors(@Nullable JSONObject innerErrors) { - List list = new ArrayList<>(); - while (innerErrors != null) { - String errorCode = innerErrors.optString(CODE_STR_KEY); - if (errorCode != null) { - list.add(errorCode); + /** parsing logic that needs to happen before calling this/super constructor */ + private static class ParsedError { + public final @NotNull String errorCode; + public final @NotNull String errorMessage; + public final @Nullable JSONObject errorObject; + + public ParsedError(@NotNull String responseBody) { + JSONObject errorResponse; + try { + errorResponse = new JSONObject(responseBody); + } catch (JSONException jsonException) { + this.errorCode = "invalidErrorResponse"; + this.errorMessage = String.valueOf(jsonException.getMessage()); + this.errorObject = null; + return; + } + JSONObject errorObject = optJsonObjectIgnoreCase(errorResponse, ERROR_OBJ_KEY); + if (errorObject == null) { + this.errorCode = "invalidErrorResponse"; + this.errorMessage = String.format("error response has no json object '%s'", ERROR_OBJ_KEY); + this.errorObject = null; + return; } - innerErrors = innerErrors.optJSONObject(INNERERROR_OBJ_KEY); - } - return list; - } - private static List parseDetails(@Nullable JSONArray details) { - if (details == null) { - return new ArrayList<>(); + this.errorCode = optStringIgnoreCase(errorObject, CODE_STR_KEY, "null"); + this.errorMessage = optStringIgnoreCase(errorObject, MESSAGE_STR_KEY, "null"); + this.errorObject = errorObject; } - List list = new ArrayList<>(details.length()); - for (int detailIdx = 0; detailIdx < details.length(); detailIdx++) { - list.add(new GraphApiErrorException(-1, details.getJSONObject(detailIdx).getJSONObject(ERROR_OBJ_KEY))); - } - return list; - } - - private GraphApiErrorException(int statusCode, @NotNull JSONObject error) { - this(statusCode, error.getString(CODE_STR_KEY), error.getString(MESSAGE_STR_KEY), - parseInnerErrors(error.optJSONObject(INNERERROR_OBJ_KEY)), - parseDetails(error.optJSONArray(DETAILS_ARR_KEY))); } - private static String toMessage(int statusCode, String errorCode, String errorMessage, List innerErrors, - List details) { - String format = "%d %s : \"%s\"%s%s"; - String inner = String.join("\", \"", innerErrors); - String detail = details.stream().map(GraphApiErrorException::getMessage).collect(Collectors.joining(" }, { ")); - if (!inner.isEmpty()) { - inner = " inner:[ \"" + inner + "\" ]"; - } - if (!detail.isEmpty()) { - detail = " details:[ { " + detail + " } ]"; + /** + * constructs a formatted error message string using the provided status code and error details. + * if ParsedError.errorObject is non-null, its included as pretty-printed json. + */ + private static @NotNull String toMessage(int statusCode, @NotNull ParsedError error) { + String format = "%d %s : \"%s\""; + String common = String.format(format, statusCode, error.errorCode, error.errorMessage); + if (error.errorObject == null) { + return common; } - return String.format(format, statusCode, errorCode, errorMessage, inner, detail); + return common + '\n' + error.errorObject.toString(2); } - private GraphApiErrorException(int statusCode, String errorCode, String errorMessage, List innerErrors, - List details) { - super(toMessage(statusCode, errorCode, errorMessage, innerErrors, details)); + private GraphApiErrorException(int statusCode, @NotNull ParsedError error) { + super(toMessage(statusCode, error)); this.statusCode = statusCode; - this.errorCode = errorCode; - this.errorMessage = errorMessage; - this.innerErrors = innerErrors; - this.details = details; + this.errorCode = error.errorCode; + this.errorMessage = error.errorMessage; + this.errorObject = error.errorObject; } } diff --git a/DriveBackup/src/main/java/ratismal/drivebackup/uploaders/onedrive/OneDriveUploader.java b/DriveBackup/src/main/java/ratismal/drivebackup/uploaders/onedrive/OneDriveUploader.java index 28ef6b81..a4236aad 100644 --- a/DriveBackup/src/main/java/ratismal/drivebackup/uploaders/onedrive/OneDriveUploader.java +++ b/DriveBackup/src/main/java/ratismal/drivebackup/uploaders/onedrive/OneDriveUploader.java @@ -87,6 +87,7 @@ private void retrieveNewAccessToken() throws Exception { .post(requestBody) .build(); try (Response response = DriveBackup.httpClient.newCall(request).execute()) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) JSONObject parsedResponse = new JSONObject(response.body().string()); if (!response.isSuccessful()) { String error = parsedResponse.optString("error"); @@ -263,10 +264,12 @@ private FQID createFolder(@NotNull FQID root, @NotNull String folder) throws IOE .post(requestBody) .build(); try (Response response = DriveBackup.httpClient.newCall(request).execute()) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + String jsonResponse = response.body().string(); if (response.code() != 201) { - throw new GraphApiErrorException(response); + throw new GraphApiErrorException(response.code(), jsonResponse); } - JSONObject parsedResponse = new JSONObject(response.body().string()); + JSONObject parsedResponse = new JSONObject(jsonResponse); String driveId = parsedResponse.getJSONObject("parentReference").getString("driveId"); String itemId = parsedResponse.getString("id"); return new FQID(driveId, itemId); @@ -295,10 +298,12 @@ private FQID createRootFolder(@NotNull String folder) throws IOException, GraphA .post(requestBody) .build(); try (Response response = DriveBackup.httpClient.newCall(request).execute()) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + String responseBody = response.body().string(); if (response.code() != 201) { - throw new GraphApiErrorException(response); + throw new GraphApiErrorException(response.code(), responseBody); } - JSONObject parsedResponse = new JSONObject(response.body().string()); + JSONObject parsedResponse = new JSONObject(responseBody); String driveId = parsedResponse.getJSONObject("parentReference").getString("driveId"); String itemId = parsedResponse.getString("id"); return new FQID(driveId, itemId); @@ -322,13 +327,15 @@ private FQID getRootFolder(@NotNull String folder) throws IOException, GraphApiE .build(); JSONObject parsedResponse; try (Response response = DriveBackup.httpClient.newCall(request).execute()) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + String responseBody = response.body().string(); if (response.code() == 404) { return null; } if (!response.isSuccessful()) { - throw new GraphApiErrorException(response); + throw new GraphApiErrorException(response.code(), responseBody); } - parsedResponse = new JSONObject(response.body().string()); + parsedResponse = new JSONObject(responseBody); } if (parsedResponse.has("remoteItem")) { parsedResponse = parsedResponse.getJSONObject("remoteItem"); @@ -386,10 +393,12 @@ private List getChildren(@NotNull FQID folder, @NotNull String query .url(targetUrl) .build(); try (Response response = DriveBackup.httpClient.newCall(request).execute()) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + String responseBody = response.body().string(); if (!response.isSuccessful()) { - throw new GraphApiErrorException(response); + throw new GraphApiErrorException(response.code(), responseBody); } - JSONObject parsedResponse = new JSONObject(response.body().string()); + JSONObject parsedResponse = new JSONObject(responseBody); JSONArray someChildren = parsedResponse.getJSONArray("value"); allChildren.ensureCapacity(parsedResponse.optInt("@odata.count", someChildren.length())); for (int i = 0; i < someChildren.length(); i++) { @@ -419,7 +428,8 @@ private void recycleItem(@NotNull String driveId, @NotNull String itemId) throws .build(); try (Response response = DriveBackup.httpClient.newCall(delteRequest).execute()) { if (response.code() != 204 && response.code() != 404) { - throw new GraphApiErrorException(response); + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + throw new GraphApiErrorException(response.code(), response.body().string()); } } } @@ -441,10 +451,12 @@ private FQID uploadSmallFile(@NotNull File file, @NotNull FQID destinationFolder .put(RequestBody.create(file, textMediaType)) .build(); try (Response response = DriveBackup.httpClient.newCall(uploadRequest).execute()) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + String responseBody = response.body().string(); if (response.code() != 201) { - throw new GraphApiErrorException(response); + throw new GraphApiErrorException(response.code(), responseBody); } - JSONObject parsedResponse = new JSONObject(response.body().string()); + JSONObject parsedResponse = new JSONObject(responseBody); return new FQID(destinationFolder.driveId, parsedResponse.getString("id")); } } @@ -468,10 +480,12 @@ private String createUploadSession(@NotNull String fileName, @NotNull FQID desti .post(RequestBody.create("{}", jsonMediaType)) .build(); try (Response response = DriveBackup.httpClient.newCall(request).execute()) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + String responseBody = response.body().string(); if (!response.isSuccessful()) { - throw new GraphApiErrorException(response); + throw new GraphApiErrorException(response.code(), responseBody); } - return new JSONObject(response.body().string()).getString("uploadUrl"); + return new JSONObject(responseBody).getString("uploadUrl"); } } @@ -489,48 +503,69 @@ private String createUploadSession(@NotNull String fileName, @NotNull FQID desti */ private void uploadToSession(@NotNull String uploadURL, @NotNull RandomAccessFile randomAccessFile) throws IOException, GraphApiErrorException, InterruptedException { - int exponentialBackoffMillis = EXPONENTIAL_BACKOFF_MILLIS_DEFAULT; - int retryCount = 0; - Range range = new Range(0, UPLOAD_CHUNK_SIZE); + UploadSessionState state = new UploadSessionState(); while (true) { - byte[] bytesToUpload = getChunk(randomAccessFile, range); + byte[] bytesToUpload = getChunk(randomAccessFile, state.range); Request uploadRequest = new Request.Builder() .addHeader("Content-Range", String.format("bytes %d-%d/%d", - range.start, range.start + bytesToUpload.length - 1, randomAccessFile.length())) + state.range.start, state.range.start + bytesToUpload.length - 1, randomAccessFile.length())) .url(uploadURL) .put(RequestBody.create(bytesToUpload, zipMediaType)) .build(); try (Response uploadResponse = DriveBackup.httpClient.newCall(uploadRequest).execute()) { - if (uploadResponse.code() == 202) { - JSONObject responseObject = new JSONObject(uploadResponse.body().string()); - JSONArray expectedRanges = responseObject.getJSONArray("nextExpectedRanges"); - range = new Range(expectedRanges.getString(0), UPLOAD_CHUNK_SIZE); - exponentialBackoffMillis = EXPONENTIAL_BACKOFF_MILLIS_DEFAULT; - retryCount = 0; - } else if (uploadResponse.code() == 201 || uploadResponse.code() == 200) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + String responseBody = uploadResponse.body().string(); + int statusCode = uploadResponse.code(); + if (statusCode == 202) { + uploadAccepted(state, responseBody); + } else if (statusCode == 201 || statusCode == 200) { break; } else { - if (retryCount > MAX_RETRY_ATTEMPTS || uploadResponse.code() == 409) { - Request cancelRequest = new Request.Builder().url(uploadURL).delete().build(); - DriveBackup.httpClient.newCall(cancelRequest).execute().close(); - throw new GraphApiErrorException(uploadResponse); - } else if (uploadResponse.code() == 404) { - throw new GraphApiErrorException(uploadResponse); - } else if (uploadResponse.code() == 416) { - Request statusRequest = new Request.Builder().url(uploadURL).build(); - try (Response statusResponse = DriveBackup.httpClient.newCall(statusRequest).execute()) { - JSONObject responseObject = new JSONObject(statusResponse.body().string()); - JSONArray expectedRanges = responseObject.getJSONArray("nextExpectedRanges"); - range = new Range(expectedRanges.getString(0), UPLOAD_CHUNK_SIZE); - } - } else if (uploadResponse.code() >= 500 && uploadResponse.code() < 600) { - TimeUnit.MILLISECONDS.sleep(exponentialBackoffMillis); - exponentialBackoffMillis *= EXPONENTIAL_BACKOFF_FACTOR; - } - retryCount++; + uploadRetryOrFailure(state, uploadURL, statusCode, responseBody); + } + } + } + } + + /** + * handles 202:Accepted upload responses + */ + private void uploadAccepted(@NotNull UploadSessionState state, @NotNull String uploadResponseBody) { + JSONObject responseObject = new JSONObject(uploadResponseBody); + JSONArray expectedRanges = responseObject.getJSONArray("nextExpectedRanges"); + state.range = new Range(expectedRanges.getString(0), UPLOAD_CHUNK_SIZE); + state.exponentialBackoffMillis = EXPONENTIAL_BACKOFF_MILLIS_DEFAULT; + state.retryCount = 0; + } + + /** + * handles non-success upload responses + */ + private void uploadRetryOrFailure(@NotNull UploadSessionState state, @NotNull String uploadURL, int statusCode, + @NotNull String uploadResponseBody) throws IOException, GraphApiErrorException, InterruptedException { + if (state.retryCount > MAX_RETRY_ATTEMPTS || statusCode == 409) { + Request cancelRequest = new Request.Builder().url(uploadURL).delete().build(); + DriveBackup.httpClient.newCall(cancelRequest).execute().close(); + throw new GraphApiErrorException(statusCode, uploadResponseBody); + } else if (statusCode == 404) { + throw new GraphApiErrorException(statusCode, uploadResponseBody); + } else if (statusCode == 416) { + Request statusRequest = new Request.Builder().url(uploadURL).build(); + try (Response statusResponse = DriveBackup.httpClient.newCall(statusRequest).execute()) { + //noinspection DataFlowIssue (response.body() is non-null after Call.execute()) + String statusResponseBody = statusResponse.body().string(); + if (!statusResponse.isSuccessful()) { + throw new GraphApiErrorException(statusResponse.code(), statusResponseBody); } + JSONObject responseObject = new JSONObject(statusResponseBody); + JSONArray expectedRanges = responseObject.getJSONArray("nextExpectedRanges"); + state.range = new Range(expectedRanges.getString(0), UPLOAD_CHUNK_SIZE); } + } else if (statusCode >= 500 && statusCode < 600) { + TimeUnit.MILLISECONDS.sleep(state.exponentialBackoffMillis); + state.exponentialBackoffMillis *= EXPONENTIAL_BACKOFF_FACTOR; } + state.retryCount++; } /** @@ -564,6 +599,15 @@ private void pruneBackups(@NotNull FQID parent) throws IOException, GraphApiErro } } + /** + * upload session state + */ + private static final class UploadSessionState { + private int exponentialBackoffMillis = EXPONENTIAL_BACKOFF_MILLIS_DEFAULT; + private int retryCount = 0; + private Range range = new Range(0, UPLOAD_CHUNK_SIZE); + } + /** * A range of bytes */ diff --git a/DriveBackup/src/main/java/ratismal/drivebackup/util/JsonUtil.java b/DriveBackup/src/main/java/ratismal/drivebackup/util/JsonUtil.java new file mode 100644 index 00000000..034d4c3a --- /dev/null +++ b/DriveBackup/src/main/java/ratismal/drivebackup/util/JsonUtil.java @@ -0,0 +1,58 @@ +package ratismal.drivebackup.util; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; + +import java.util.Iterator; + +public class JsonUtil { + /** + * tries to get the {@link JSONObject} associated with a key, ignoring case differences + * + * @param json object to lookup in + * @param key string to compare against + * @return {@link JSONObject} or null if the value was not found or is not a {@link JSONObject} + */ + @Nullable + public static JSONObject optJsonObjectIgnoreCase(@NotNull JSONObject json, @NotNull String key) { + return json.optJSONObject(findKeyIgnoreCase(json, key)); + } + + /** + * tries to get the {@link String} associated with a key, ignoring case differences. if the value is not a string + * and is not null, then it is converted to a string + * + * @param json object to lookup in + * @param key string to compare against + * @param alt string to return if not found + * @return {@link String} or alt if the value was not found + */ + @NotNull + public static String optStringIgnoreCase(@NotNull JSONObject json, @NotNull String key, @NotNull String alt) { + return json.optString(findKeyIgnoreCase(json, key), alt); + } + + /** + * tries to find a key in json that matches the given key, ignoring case differences. an exact match is preferred. + * otherwise any case-insensitive match is returned, or null if nothing is found. + * + * @param json object to search in + * @param key string to search for + * @return the matching key if found, or null + */ + @Nullable + public static String findKeyIgnoreCase(@NotNull JSONObject json, @NotNull String key) { + if (json.has(key)) { + return key; + } + Iterator keyIt = json.keys(); + while (keyIt.hasNext()) { + String member = keyIt.next(); + if (member.equalsIgnoreCase(key)) { + return member; + } + } + return null; + } +}