OneDriveConnection.java

/*
 * onedrive-java-sdk - A Java SDK to access OneDrive drives and files.
 * Copyright © 2023-2025 Andy Miles (andy.miles@amilesend.com)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */
package com.amilesend.onedrive.connection;

import com.amilesend.client.connection.Connection;
import com.amilesend.client.connection.ConnectionException;
import com.amilesend.client.connection.RequestException;
import com.amilesend.client.connection.ResponseException;
import com.amilesend.client.connection.file.TransferFileWriter;
import com.amilesend.client.connection.file.TransferProgressCallback;
import com.amilesend.client.parse.parser.GsonParser;
import com.amilesend.onedrive.connection.auth.OneDriveAuthManager;
import com.amilesend.onedrive.parse.GsonFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.gson.Gson;
import lombok.NonNull;
import lombok.experimental.SuperBuilder;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.zip.GZIPInputStream;

import static com.google.common.net.HttpHeaders.CONTENT_ENCODING;
import static com.google.common.net.HttpHeaders.CONTENT_TYPE;

/**
 * Wraps a {@link OkHttpClient} that manages authentication refresh and parsing responses to corresponding POJO types.
 * Construct a new instance via the {@link OneDriveConnectionBuilder} that simplifies configuration of the
 * {@link OneDriveAuthManager} and automatically sets a pre-configured {@link Gson} instance for proper request/response
 * serialization.
 *
 * @see OneDriveConnectionBuilder
 * @see OneDriveAuthManager
 */
@SuperBuilder
@Slf4j
public class OneDriveConnection extends Connection<GsonFactory> {
    /**
     * Creates a new {@link Request.Builder} with pre-configured headers for a request that contains both a
     * JSON-formatted request and response body.
     *
     * @return the request builder
     */
    public Request.Builder newWithBodyRequestBuilder() {
        return newRequestBuilder().addHeader(CONTENT_TYPE,  JSON_CONTENT_TYPE);
    }

    /**
     * Executes the given {@link Request} for a remote asynchronous operation and returns the monitoring URL.
     *
     * @param request the request
     * @return the monitoring URL to track the remote asynchronous operation
     * @throws ConnectionException if an error occurred during the transaction
     */
    public String executeRemoteAsync(@NonNull final Request request) throws ConnectionException {
        try {
            try (final Response response = getHttpClient().newCall(request).execute()) {
                validateResponseCode(response);
                final int code = response.code();
                // Specific to remote async operations
                if (code != 202) {
                    throw new ResponseException("Expected a 202 response code. Got " + code);
                }

                return response.header("Location");
            }
        } catch (final IOException ex) {
            throw new RequestException("Unable to execute request: " + ex.getMessage(), ex);
        }
    }

    /**
     * Executes the given {@link Request} and parses the JSON-formatted response with given {@link GsonParser}.
     *
     * @param request the request
     * @param parser the parser to decode the response body
     * @return the CompletableFuture used to fetch the parsed response or failure exception reason
     * @param <T> the POJO resource type
     */
    public <T> CompletableFuture<T> executeAsync(
            @NonNull final Request request,
            @NonNull final GsonParser<T> parser) {
        final OneDriveConnection connectionRef = this;
        final CompletableFuture<T> future = new CompletableFuture<>();
        getHttpClient().newCall(request).enqueue(new Callback() {
            @Override
            public void onFailure(@NonNull final Call call, @NonNull final IOException ex) {
                future.completeExceptionally(ex);
            }

            @Override
            public void onResponse(@NonNull final Call call, @NonNull final Response response) throws IOException {
                try {
                    validateResponseCode(response);
                    final InputStream responseBodyInputStream =
                            StringUtils.equalsIgnoreCase("gzip", response.header(CONTENT_ENCODING))
                                    ? new GZIPInputStream(response.body().byteStream())
                                    : response.body().byteStream();
                    future.complete(parser.parse(getGsonFactory().getInstance(connectionRef), responseBodyInputStream));
                } catch (final Exception ex) {
                    future.completeExceptionally(ex);
                } finally {
                    response.close();
                }
            }
        });

        return future;
    }

    /**
     * Downloads the contents for the given {@code request} to the specified {@code folderPath} and {@code name}.
     *
     * @param request the request
     * @param folderPath the path of the folder to download the contents to
     * @param name the name of the file to download the contents to
     * @param sizeBytes the total size of the expected file in bytes
     * @param callback the {@link TransferProgressCallback} call to invoke to report download transfer progress
     * @return the size of the downloaded file in bytes
     * @throws ConnectionException if an error occurred while downloading the content for the request
     */
    public long download(
            @NonNull final Request request,
            @NonNull final Path folderPath,
            final String name,
            final long sizeBytes,
            @NonNull final TransferProgressCallback callback) throws ConnectionException {
        Validate.notBlank(name, "name must not be blank");

        final Path downloadPath;
        try {
            downloadPath = checkFolderAndGetDestinationPath(folderPath, name);
        } catch (final Exception ex) {
            callback.onFailure(ex);
            throw new RequestException("Unable to determine download path:" + ex.getMessage(), ex);
        }

        try (final Response response = getHttpClient().newCall(request).execute()) {
                return processDownloadResponse(response, downloadPath, sizeBytes, callback);
        } catch (final ConnectionException ex) {
            // Response failed validation, notify the callback
            callback.onFailure(ex);
            throw ex;
        } catch (final Exception ex) {
            // The underlying TransferFileWriter will record an onFailure to the callback.
            throw new RequestException("Unable to execute request: " + ex.getMessage(), ex);
        }
    }

    /**
     * Downloads the contents for the given {@code request} asynchronously to the specified {@code folderPath} and
     * {@code name}.
     *
     * @param request the request
     * @param folderPath the path of the folder to download the contents to
     * @param name the name of the file to download the contents to
     * @param sizeBytes the total size of the expected file in bytes
     * @param callback the {@link TransferProgressCallback} call to invoke to report download transfer progress
     * @return the CompletableFuture used to fetch the number of bytes downloaded
     */
    public CompletableFuture<Long> downloadAsync(
            @NonNull final Request request,
            @NonNull final Path folderPath,
            final String name,
            final long sizeBytes,
            @NonNull final TransferProgressCallback callback) {
        Validate.notBlank(name, "name must not be blank");
        final CompletableFuture<Long> future = new CompletableFuture<>();

        getHttpClient().newCall(request).enqueue(new Callback() {
            @Override
            public void onFailure(@NonNull final Call call, @NonNull final IOException ex) {
                callback.onFailure(ex);
                future.completeExceptionally(ex);
            }

            @Override
            public void onResponse(@NonNull final Call call, @NonNull final Response response) throws IOException {
                final Path downloadPath;
                try {
                    downloadPath = checkFolderAndGetDestinationPath(folderPath, name);
                } catch (final Exception ex) {
                    callback.onFailure(ex);
                    future.completeExceptionally(ex);
                    throw new RequestException("Unable to determine download path:" + ex.getMessage(), ex);
                }

                try {
                    final long totalBytes = processDownloadResponse(response, downloadPath, sizeBytes, callback);
                    future.complete(Long.valueOf(totalBytes));
                } catch (final ConnectionException ex) {
                    // Response failed validation, notify the callback
                    callback.onFailure(ex);
                    future.completeExceptionally(ex);
                    throw ex;
                } catch (final Exception ex) {
                    // The underlying TransferFileWriter will record an onFailure to the callback.
                    future.completeExceptionally(ex);
                    throw ex;
                } finally {
                    response.close();
                }
            }
        });

        return future;
    }

    @VisibleForTesting
    long processDownloadResponse(
            final Response response,
            final Path downloadPath,
            final long sizeBytes,
            final TransferProgressCallback callback) throws IOException {
        validateResponseCode(response);

        final long totalBytes = TransferFileWriter.builder()
                .output(downloadPath)
                .callback(callback)
                .build()
                .write(response.body().source(), sizeBytes);

        if (log.isDebugEnabled()) {
            log.debug("Downloaded [{}] bytes to [{}]", totalBytes, downloadPath);
        }
        return totalBytes;
    }

    @VisibleForTesting
    Path checkFolderAndGetDestinationPath(final Path folderPath, final String name) throws IOException {
        final Path normalizedFolderPath = folderPath.toAbsolutePath().normalize();
        if (Files.exists(normalizedFolderPath) && !Files.isDirectory(normalizedFolderPath)) {
            throw new IllegalArgumentException(normalizedFolderPath + " must not already exist as a file");
        }

        Files.createDirectories(normalizedFolderPath);
        return normalizedFolderPath.resolve(name);
    }
}