Repository: googleapis/java-storage (java)

Issue:
Blob.downloadTo() methods do not wrap RetryHelper$RetryHelperException
Quoting source code for one of downloadTo methods:
```
  /**
   * Downloads this blob to the given file path using specified blob read options.
   *
   * @param path destination
   * @param options blob read options
   * @throws StorageException upon failure
   */
  public void downloadTo(Path path, BlobSourceOption... options) {
```
However `StorageException` is never throwing, but confusing `RetryHelper$RetryHelperException` is throwing instead:
```
        String bucketName = "my-bucket";
        String blobName = "my.txt";
        BlobInfo blobInfo = BlobInfo.newBuilder(bucketName, blobName).build();
        String key = "JVzfVl8NLD9FjedFuStegjRfES5ll5zc59CIXw572OA=";

        Blob blob1 = storage.create(blobInfo, Storage.BlobTargetOption.encryptionKey(key));
        blob1.writer().write(ByteBuffer.wrap("Hello".getBytes("UTF8")));

        Blob blob2 = storage.get(blobInfo.getBlobId());
        Path tempFileTo = Files.createTempFile("my_", ".tmp");
        try {
            blob2.downloadTo(tempFileTo);
        } catch (StorageException e) {
            // nice handling
        }
```
Output:
```
Exception in thread "main" com.google.cloud.RetryHelper$RetryHelperException: com.google.cloud.storage.StorageException: 400 Bad Request
The target object is encrypted by a customer-supplied encryption key.
    at com.google.cloud.RetryHelper.runWithRetries(RetryHelper.java:54)
    at com.google.cloud.storage.Blob.downloadTo(Blob.java:236)
    at com.google.cloud.storage.Blob.downloadTo(Blob.java:220)
    at com.google.cloud.storage.Blob.downloadTo(Blob.java:263)
   ...
```


Pull request description:
fix: Blob.downloadTo() methods do not wrap RetryHelper$RetryHelperException
Fixes #100 


Patch:
diff --git a/.kokoro/build.sh b/.kokoro/build.sh
index f833a062..86bb6be3 100755
--- a/.kokoro/build.sh
+++ b/.kokoro/build.sh
@@ -20,17 +20,22 @@ scriptDir=$(realpath $(dirname "${BASH_SOURCE[0]}"))
 ## cd to the parent directory, i.e. the root of the git repo
 cd ${scriptDir}/..
 
+# include common functions
+source ${scriptDir}/common.sh
+
 # Print out Java version
 java -version
 echo ${JOB_TYPE}
 
-mvn install -B -V \
-  -DskipTests=true \
-  -Dclirr.skip=true \
-  -Denforcer.skip=true \
-  -Dmaven.javadoc.skip=true \
-  -Dgcloud.download.skip=true \
-  -T 1C
+# attempt to install 3 times with exponential backoff (starting with 10 seconds)
+retry_with_backoff 3 10 \
+  mvn install -B -V \
+    -DskipTests=true \
+    -Dclirr.skip=true \
+    -Denforcer.skip=true \
+    -Dmaven.javadoc.skip=true \
+    -Dgcloud.download.skip=true \
+    -T 1C
 
 # if GOOGLE_APPLICATION_CREDIENTIALS is specified as a relative path prepend Kokoro root directory onto it
 if [[ ! -z "${GOOGLE_APPLICATION_CREDENTIALS}" && "${GOOGLE_APPLICATION_CREDENTIALS}" != /* ]]; then
diff --git a/.kokoro/common.sh b/.kokoro/common.sh
new file mode 100644
index 00000000..a3bbc5f6
--- /dev/null
+++ b/.kokoro/common.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Copyright 2020 Google LLC
+#
+# 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.
+
+# set -eo pipefail
+
+function retry_with_backoff {
+    attempts_left=$1
+    sleep_seconds=$2
+    shift 2
+    command=$@
+
+    echo "${command}"
+    ${command}
+    exit_code=$?
+
+    if [[ $exit_code == 0 ]]
+    then
+        return 0
+    fi
+
+    # failure
+    if [[ ${attempts_left} > 0 ]]
+    then
+        echo "failure (${exit_code}), sleeping ${sleep_seconds}..."
+        sleep ${sleep_seconds}
+        new_attempts=$((${attempts_left} - 1))
+        new_sleep=$((${sleep_seconds} * 2))
+        retry_with_backoff ${new_attempts} ${new_sleep} ${command}
+    fi
+
+    return $exit_code
+}
diff --git a/.kokoro/dependencies.sh b/.kokoro/dependencies.sh
index 04a288b1..0aade871 100755
--- a/.kokoro/dependencies.sh
+++ b/.kokoro/dependencies.sh
@@ -15,7 +15,13 @@
 
 set -eo pipefail
 
-cd github/java-storage/
+## Get the directory of the build script
+scriptDir=$(realpath $(dirname "${BASH_SOURCE[0]}"))
+## cd to the parent directory, i.e. the root of the git repo
+cd ${scriptDir}/..
+
+# include common functions
+source ${scriptDir}/common.sh
 
 # Print out Java
 java -version
@@ -24,8 +30,9 @@ echo $JOB_TYPE
 export MAVEN_OPTS="-Xmx1024m -XX:MaxPermSize=128m"
 
 # this should run maven enforcer
-mvn install -B -V \
-  -DskipTests=true \
-  -Dclirr.skip=true
+retry_with_backoff 3 10 \
+  mvn install -B -V \
+    -DskipTests=true \
+    -Dclirr.skip=true
 
 mvn -B dependency:analyze -DfailOnWarning=true
diff --git a/.kokoro/linkage-monitor.sh b/.kokoro/linkage-monitor.sh
index 158641cb..759ab4e2 100755
--- a/.kokoro/linkage-monitor.sh
+++ b/.kokoro/linkage-monitor.sh
@@ -17,18 +17,26 @@ set -eo pipefail
 # Display commands being run.
 set -x
 
-cd github/java-storage/
+## Get the directory of the build script
+scriptDir=$(realpath $(dirname "${BASH_SOURCE[0]}"))
+## cd to the parent directory, i.e. the root of the git repo
+cd ${scriptDir}/..
+
+# include common functions
+source ${scriptDir}/common.sh
 
 # Print out Java version
 java -version
 echo ${JOB_TYPE}
 
-mvn install -B -V \
-  -DskipTests=true \
-  -Dclirr.skip=true \
-  -Denforcer.skip=true \
-  -Dmaven.javadoc.skip=true \
-  -Dgcloud.download.skip=true
+# attempt to install 3 times with exponential backoff (starting with 10 seconds)
+retry_with_backoff 3 10 \
+  mvn install -B -V \
+    -DskipTests=true \
+    -Dclirr.skip=true \
+    -Denforcer.skip=true \
+    -Dmaven.javadoc.skip=true \
+    -Dgcloud.download.skip=true
 
 # Kokoro job cloud-opensource-java/ubuntu/linkage-monitor-gcs creates this JAR
 JAR=linkage-monitor-latest-all-deps.jar
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Blob.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Blob.java
index 27e62626..7efc24c9 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Blob.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Blob.java
@@ -26,6 +26,7 @@ import com.google.api.services.storage.model.StorageObject;
 import com.google.auth.ServiceAccountSigner;
 import com.google.auth.ServiceAccountSigner.SigningException;
 import com.google.cloud.ReadChannel;
+import com.google.cloud.RetryHelper;
 import com.google.cloud.Tuple;
 import com.google.cloud.WriteChannel;
 import com.google.cloud.storage.Acl.Entity;
@@ -230,21 +231,25 @@ public class Blob extends BlobInfo {
     final CountingOutputStream countingOutputStream = new CountingOutputStream(outputStream);
     final StorageRpc storageRpc = this.options.getStorageRpcV1();
     final Map<StorageRpc.Option, ?> requestOptions = StorageImpl.optionMap(getBlobId(), options);
-    runWithRetries(
-        callable(
-            new Runnable() {
-              @Override
-              public void run() {
-                storageRpc.read(
-                    getBlobId().toPb(),
-                    requestOptions,
-                    countingOutputStream.getCount(),
-                    countingOutputStream);
-              }
-            }),
-        this.options.getRetrySettings(),
-        StorageImpl.EXCEPTION_HANDLER,
-        this.options.getClock());
+    try {
+      runWithRetries(
+          callable(
+              new Runnable() {
+                @Override
+                public void run() {
+                  storageRpc.read(
+                      getBlobId().toPb(),
+                      requestOptions,
+                      countingOutputStream.getCount(),
+                      countingOutputStream);
+                }
+              }),
+          this.options.getRetrySettings(),
+          StorageImpl.EXCEPTION_HANDLER,
+          this.options.getClock());
+    } catch (RetryHelper.RetryHelperException e) {
+      StorageException.translateAndThrow(e);
+    }
   }
 
   /**
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOperations.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOperations.java
new file mode 100644
index 00000000..6ff178ff
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOperations.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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.google.cloud.storage;
+
+import com.google.cloud.WriteChannel;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/** Utility methods to perform various operations with the Storage such as upload. */
+public final class StorageOperations {
+
+  private final Storage storage;
+  private static final int DEFAULT_BUFFER_SIZE = 15 * 1024 * 1024;
+  private static final int MIN_BUFFER_SIZE = 256 * 1024;
+
+  /**
+   * Creates a new StorageOperations instance associated with the given storage.
+   *
+   * @param storage the Storage
+   */
+  public StorageOperations(Storage storage) {
+    this.storage = storage;
+  }
+
+  /**
+   * Uploads {@code path} to the blob using {@link Storage#writer}. By default any MD5 and CRC32C
+   * values in the given {@code blobInfo} are ignored unless requested via the {@link
+   * Storage.BlobWriteOption#md5Match()} and {@link Storage.BlobWriteOption#crc32cMatch()} options.
+   * Folder upload is not supported.
+   *
+   * <p>Example of uploading a file:
+   *
+   * <pre>{@code
+   * String bucketName = "my-unique-bucket";
+   * String fileName = "readme.txt";
+   * BlobId blobId = BlobId.of(bucketName, fileName);
+   * BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType("text/plain").build();
+   * new StorageOperations(storage).upload(blobInfo, Paths.get(fileName));
+   * }</pre>
+   *
+   * @param blobInfo blob to create
+   * @param path file to upload
+   * @param options blob write options
+   * @throws IOException on I/O error
+   * @throws StorageException on failure
+   * @see #upload(BlobInfo, Path, int, Storage.BlobWriteOption...)
+   */
+  public void upload(BlobInfo blobInfo, Path path, Storage.BlobWriteOption... options)
+      throws IOException {
+    upload(blobInfo, path, DEFAULT_BUFFER_SIZE, options);
+  }
+
+  /**
+   * Uploads {@code path} to the blob using {@link Storage#writer} and {@code bufferSize}. By
+   * default any MD5 and CRC32C values in the given {@code blobInfo} are ignored unless requested
+   * via the {@link Storage.BlobWriteOption#md5Match()} and {@link
+   * Storage.BlobWriteOption#crc32cMatch()} options. Folder upload is not supported.
+   *
+   * <p>{@link #upload(BlobInfo, Path, Storage.BlobWriteOption...)} invokes this method with a
+   * buffer size of 15 MiB. Users can pass alternative values. Larger buffer sizes might improve the
+   * upload performance but require more memory. This can cause an OutOfMemoryError or add
+   * significant garbage collection overhead. Smaller buffer sizes reduce memory consumption, that
+   * is noticeable when uploading many objects in parallel. Buffer sizes less than 256 KiB are
+   * treated as 256 KiB.
+   *
+   * <p>Example of uploading a humongous file:
+   *
+   * <pre>{@code
+   * BlobId blobId = BlobId.of(bucketName, blobName);
+   * BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType("video/webm").build();
+   *
+   * int largeBufferSize = 150 * 1024 * 1024;
+   * Path file = Paths.get("humongous.file");
+   * new StorageOperations(storage).upload(blobInfo, file, largeBufferSize);
+   * }</pre>
+   *
+   * @param blobInfo blob to create
+   * @param path file to upload
+   * @param bufferSize size of the buffer I/O operations
+   * @param options blob write options
+   * @throws IOException on I/O error
+   * @throws StorageException on failure
+   */
+  public void upload(
+      BlobInfo blobInfo, Path path, int bufferSize, Storage.BlobWriteOption... options)
+      throws IOException {
+    if (Files.isDirectory(path)) {
+      throw new StorageException(0, path + " is a directory");
+    }
+    try (InputStream input = Files.newInputStream(path)) {
+      upload(blobInfo, input, bufferSize, options);
+    }
+  }
+
+  /**
+   * Reads bytes from an input stream and uploads those bytes to the blob using {@link
+   * Storage#writer}. By default any MD5 and CRC32C values in the given {@code blobInfo} are ignored
+   * unless requested via the {@link Storage.BlobWriteOption#md5Match()} and {@link
+   * Storage.BlobWriteOption#crc32cMatch()} options.
+   *
+   * <p>Example of uploading data with CRC32C checksum:
+   *
+   * <pre>{@code
+   * BlobId blobId = BlobId.of(bucketName, blobName);
+   * byte[] content = "Hello, world".getBytes(StandardCharsets.UTF_8);
+   * Hasher hasher = Hashing.crc32c().newHasher().putBytes(content);
+   * String crc32c = BaseEncoding.base64().encode(Ints.toByteArray(hasher.hash().asInt()));
+   * BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setCrc32c(crc32c).build();
+   * new StorageOperations(storage).upload(blobInfo, new ByteArrayInputStream(content),
+   *     Storage.BlobWriteOption.crc32cMatch());
+   * }</pre>
+   *
+   * @param blobInfo blob to create
+   * @param content input stream to read from
+   * @param options blob write options
+   * @throws IOException on I/O error
+   * @throws StorageException on failure
+   * @see #upload(BlobInfo, InputStream, int, Storage.BlobWriteOption...)
+   */
+  public void upload(BlobInfo blobInfo, InputStream content, Storage.BlobWriteOption... options)
+      throws IOException {
+    upload(blobInfo, content, DEFAULT_BUFFER_SIZE, options);
+  }
+
+  /**
+   * Reads bytes from an input stream and uploads those bytes to the blob using {@link
+   * Storage#writer} and {@code bufferSize}. By default any MD5 and CRC32C values in the given
+   * {@code blobInfo} are ignored unless requested via the {@link
+   * Storage.BlobWriteOption#md5Match()} and {@link Storage.BlobWriteOption#crc32cMatch()} options.
+   *
+   * <p>{@link #upload(BlobInfo, InputStream, Storage.BlobWriteOption...)} )} invokes this method
+   * with a buffer size of 15 MiB. Users can pass alternative values. Larger buffer sizes might
+   * improve the upload performance but require more memory. This can cause an OutOfMemoryError or
+   * add significant garbage collection overhead. Smaller buffer sizes reduce memory consumption,
+   * that is noticeable when uploading many objects in parallel. Buffer sizes less than 256 KiB are
+   * treated as 256 KiB.
+   *
+   * @param blobInfo blob to create
+   * @param content input stream to read from
+   * @param bufferSize size of the buffer I/O operations
+   * @param options blob write options
+   * @throws IOException on I/O error
+   * @throws StorageException on failure
+   */
+  public void upload(
+      BlobInfo blobInfo, InputStream content, int bufferSize, Storage.BlobWriteOption... options)
+      throws IOException {
+    try (WriteChannel writer = storage.writer(blobInfo, options)) {
+      upload(Channels.newChannel(content), writer, bufferSize);
+    }
+  }
+
+  /*
+   * Uploads the given content to the storage using specified write channel and the given buffer
+   * size. This method does not close any channels.
+   */
+  private static void upload(ReadableByteChannel reader, WriteChannel writer, int bufferSize)
+      throws IOException {
+    bufferSize = Math.max(bufferSize, MIN_BUFFER_SIZE);
+    ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
+    writer.setChunkSize(bufferSize);
+
+    while (reader.read(buffer) >= 0) {
+      buffer.flip();
+      writer.write(buffer);
+      buffer.clear();
+    }
+  }
+}
diff --git a/renovate.json b/renovate.json
index fc641270..1dcdf893 100644
--- a/renovate.json
+++ b/renovate.json
@@ -56,7 +56,9 @@
     },
     {
       "packagePatterns": [
-        "^com.google.cloud:libraries-bom"
+        "^com.google.cloud:google-cloud-storage",
+        "^com.google.cloud:libraries-bom",
+        "^com.google.cloud.samples:shared-configuration"
       ],
       "semanticCommitType": "chore",
       "semanticCommitScope": "deps"
@@ -75,4 +77,4 @@
     }
   ],
   "semanticCommits": true
-}
+}
\ No newline at end of file
diff --git a/synth.metadata b/synth.metadata
index 66cd387f..1a8f7a39 100644
--- a/synth.metadata
+++ b/synth.metadata
@@ -1,11 +1,11 @@
 {
-  "updateTime": "2020-03-17T18:53:43.239577Z",
+  "updateTime": "2020-03-30T22:54:24.805818Z",
   "sources": [
     {
-      "template": {
-        "name": "java_library",
-        "origin": "synthtool.gcp",
-        "version": "2020.2.4"
+      "git": {
+        "name": "synthtool",
+        "remote": "https://github.com/googleapis/synthtool.git",
+        "sha": "a003d8655d3ebec2bbbd5fc3898e91e152265c67"
       }
     }
   ]


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/BlobTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/BlobTest.java
index 6f91f968..51d606c2 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/BlobTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/BlobTest.java
@@ -32,6 +32,7 @@ import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 import com.google.api.core.ApiClock;
 import com.google.api.gax.retrying.RetrySettings;
@@ -585,17 +586,22 @@ public class BlobTest {
     assertTrue(blob.isDirectory());
   }
 
-  @Test
-  public void testDownload() throws Exception {
-    final byte[] expected = {1, 2};
+  private StorageRpc prepareForDownload() {
     StorageRpc mockStorageRpc = createNiceMock(StorageRpc.class);
-    expect(storage.getOptions()).andReturn(mockOptions).times(1);
+    expect(storage.getOptions()).andReturn(mockOptions);
     replay(storage);
     expect(mockOptions.getStorageRpcV1()).andReturn(mockStorageRpc);
     expect(mockOptions.getRetrySettings()).andReturn(RETRY_SETTINGS);
     expect(mockOptions.getClock()).andReturn(API_CLOCK);
     replay(mockOptions);
     blob = new Blob(storage, new BlobInfo.BuilderImpl(BLOB_INFO));
+    return mockStorageRpc;
+  }
+
+  @Test
+  public void testDownloadTo() throws Exception {
+    final byte[] expected = {1, 2};
+    StorageRpc mockStorageRpc = prepareForDownload();
     expect(
             mockStorageRpc.read(
                 anyObject(StorageObject.class),
@@ -618,16 +624,9 @@ public class BlobTest {
   }
 
   @Test
-  public void testDownloadWithRetries() throws Exception {
+  public void testDownloadToWithRetries() throws Exception {
     final byte[] expected = {1, 2};
-    StorageRpc mockStorageRpc = createNiceMock(StorageRpc.class);
-    expect(storage.getOptions()).andReturn(mockOptions);
-    replay(storage);
-    expect(mockOptions.getStorageRpcV1()).andReturn(mockStorageRpc);
-    expect(mockOptions.getRetrySettings()).andReturn(RETRY_SETTINGS);
-    expect(mockOptions.getClock()).andReturn(API_CLOCK);
-    replay(mockOptions);
-    blob = new Blob(storage, new BlobInfo.BuilderImpl(BLOB_INFO));
+    StorageRpc mockStorageRpc = prepareForDownload();
     expect(
             mockStorageRpc.read(
                 anyObject(StorageObject.class),
@@ -662,4 +661,25 @@ public class BlobTest {
     byte actual[] = Files.readAllBytes(file.toPath());
     assertArrayEquals(expected, actual);
   }
+
+  @Test
+  public void testDownloadToWithException() throws Exception {
+    StorageRpc mockStorageRpc = prepareForDownload();
+    Exception exception = new IllegalStateException("test");
+    expect(
+            mockStorageRpc.read(
+                anyObject(StorageObject.class),
+                anyObject(Map.class),
+                eq(0l),
+                anyObject(OutputStream.class)))
+        .andThrow(exception);
+    replay(mockStorageRpc);
+    File file = File.createTempFile("blob", ".tmp");
+    try {
+      blob.downloadTo(file.toPath());
+      fail();
+    } catch (StorageException e) {
+      assertSame(exception, e.getCause());
+    }
+  }
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOperationsTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOperationsTest.java
new file mode 100644
index 00000000..0651c1ac
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOperationsTest.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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.google.cloud.storage;
+
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createStrictMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+import com.google.cloud.WriteChannel;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class StorageOperationsTest {
+  private Storage storage;
+  private StorageOperations storageOperations;
+
+  private static final BlobInfo BLOB_INFO = BlobInfo.newBuilder("b", "n").build();
+  private static final int DEFAULT_BUFFER_SIZE = 15 * 1024 * 1024;
+  private static final int MIN_BUFFER_SIZE = 256 * 1024;
+
+  @Before
+  public void setUp() {
+    storage = createStrictMock(Storage.class);
+    storageOperations = new StorageOperations(storage);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    verify(storage);
+  }
+
+  @Test
+  public void testUploadFromNonExistentFile() {
+    replay(storage);
+    String fileName = "non_existing_file.txt";
+    try {
+      storageOperations.upload(BLOB_INFO, Paths.get(fileName));
+      storageOperations.upload(BLOB_INFO, Paths.get(fileName), -1);
+      fail();
+    } catch (IOException e) {
+      assertEquals(NoSuchFileException.class, e.getClass());
+      assertEquals(fileName, e.getMessage());
+    }
+  }
+
+  @Test
+  public void testUploadFromDirectory() throws IOException {
+    replay(storage);
+    Path dir = Files.createTempDirectory("unit_");
+    try {
+      storageOperations.upload(BLOB_INFO, dir);
+      storageOperations.upload(BLOB_INFO, dir, -2);
+      fail();
+    } catch (StorageException e) {
+      assertEquals(dir + " is a directory", e.getMessage());
+    }
+  }
+
+  private void prepareForUpload(BlobInfo blobInfo, byte[] bytes, Storage.BlobWriteOption... options)
+      throws Exception {
+    prepareForUpload(blobInfo, bytes, DEFAULT_BUFFER_SIZE, options);
+  }
+
+  private void prepareForUpload(
+      BlobInfo blobInfo, byte[] bytes, int bufferSize, Storage.BlobWriteOption... options)
+      throws Exception {
+    WriteChannel channel = createStrictMock(WriteChannel.class);
+    ByteBuffer expectedByteBuffer = ByteBuffer.wrap(bytes, 0, bytes.length);
+    channel.setChunkSize(bufferSize);
+    expect(channel.write(expectedByteBuffer)).andReturn(bytes.length);
+    channel.close();
+    replay(channel);
+    expect(storage.writer(blobInfo, options)).andReturn(channel);
+    replay(storage);
+  }
+
+  @Test
+  public void testUploadFromFile() throws Exception {
+    byte[] dataToSend = {1, 2, 3};
+    prepareForUpload(BLOB_INFO, dataToSend);
+    Path tempFile = Files.createTempFile("testUpload", ".tmp");
+    Files.write(tempFile, dataToSend);
+    storageOperations.upload(BLOB_INFO, tempFile);
+  }
+
+  @Test
+  public void testUploadFromStream() throws Exception {
+    byte[] dataToSend = {1, 2, 3, 4, 5};
+    Storage.BlobWriteOption[] options =
+        new Storage.BlobWriteOption[] {Storage.BlobWriteOption.crc32cMatch()};
+    prepareForUpload(BLOB_INFO, dataToSend, options);
+    InputStream input = new ByteArrayInputStream(dataToSend);
+    storageOperations.upload(BLOB_INFO, input, options);
+  }
+
+  @Test
+  public void testUploadSmallBufferSize() throws Exception {
+    byte[] dataToSend = new byte[100_000];
+    prepareForUpload(BLOB_INFO, dataToSend, MIN_BUFFER_SIZE);
+    InputStream input = new ByteArrayInputStream(dataToSend);
+    int smallBufferSize = 100;
+    storageOperations.upload(BLOB_INFO, input, smallBufferSize);
+  }
+
+  @Test
+  public void testUploadFromIOException() throws Exception {
+    IOException ioException = new IOException("message");
+    WriteChannel channel = createStrictMock(WriteChannel.class);
+    channel.setChunkSize(DEFAULT_BUFFER_SIZE);
+    expect(channel.write((ByteBuffer) anyObject())).andThrow(ioException);
+    replay(channel);
+    expect(storage.writer(eq(BLOB_INFO))).andReturn(channel);
+    replay(storage);
+    InputStream input = new ByteArrayInputStream(new byte[10]);
+    try {
+      storageOperations.upload(BLOB_INFO, input);
+      fail();
+    } catch (IOException e) {
+      assertSame(e, ioException);
+    }
+  }
+
+  @Test
+  public void testUploadMultiplePortions() throws Exception {
+    int totalSize = 400_000;
+    int bufferSize = 300_000;
+    byte[] dataToSend = new byte[totalSize];
+    dataToSend[0] = 42;
+    dataToSend[bufferSize] = 43;
+
+    WriteChannel channel = createStrictMock(WriteChannel.class);
+    channel.setChunkSize(bufferSize);
+    expect(channel.write(ByteBuffer.wrap(dataToSend, 0, bufferSize))).andReturn(1);
+    expect(channel.write(ByteBuffer.wrap(dataToSend, bufferSize, totalSize - bufferSize)))
+        .andReturn(2);
+    channel.close();
+    replay(channel);
+    expect(storage.writer(BLOB_INFO)).andReturn(channel);
+    replay(storage);
+
+    InputStream input = new ByteArrayInputStream(dataToSend);
+    storageOperations.upload(BLOB_INFO, input, bufferSize);
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java
index 6abc233f..09411792 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java
@@ -76,6 +76,7 @@ import com.google.cloud.storage.StorageBatch;
 import com.google.cloud.storage.StorageBatchResult;
 import com.google.cloud.storage.StorageClass;
 import com.google.cloud.storage.StorageException;
+import com.google.cloud.storage.StorageOperations;
 import com.google.cloud.storage.StorageOptions;
 import com.google.cloud.storage.StorageRoles;
 import com.google.cloud.storage.testing.RemoteStorageHelper;
@@ -103,6 +104,8 @@ import java.io.InputStream;
 import java.net.URL;
 import java.net.URLConnection;
 import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.security.Key;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -131,6 +134,7 @@ public class ITStorageTest {
 
   private static RemoteStorageHelper remoteStorageHelper;
   private static Storage storage;
+  private static StorageOperations storageOperations;
   private static String kmsKeyOneResourcePath;
   private static String kmsKeyTwoResourcePath;
   private static Metadata requestParamsHeader = new Metadata();
@@ -175,6 +179,7 @@ public class ITStorageTest {
   public static void beforeClass() throws IOException {
     remoteStorageHelper = RemoteStorageHelper.create();
     storage = remoteStorageHelper.getOptions().getService();
+    storageOperations = new StorageOperations(storage);
 
     storage.create(
         BucketInfo.newBuilder(BUCKET)
@@ -3208,4 +3213,43 @@ public class ITStorageTest {
       RemoteStorageHelper.forceDelete(storage, loggingBucket, 5, TimeUnit.SECONDS);
     }
   }
+
+  @Test
+  public void testUploadFromDownloadTo() throws Exception {
+    String blobName = "test-uploadFrom-downloadTo-blob";
+    BlobId blobId = BlobId.of(BUCKET, blobName);
+    BlobInfo blobInfo = BlobInfo.newBuilder(blobId).build();
+
+    Path tempFileFrom = Files.createTempFile("ITStorageTest_", ".tmp");
+    Files.write(tempFileFrom, BLOB_BYTE_CONTENT);
+    storageOperations.upload(blobInfo, tempFileFrom);
+
+    Path tempFileTo = Files.createTempFile("ITStorageTest_", ".tmp");
+    storage.get(blobId).downloadTo(tempFileTo);
+    byte[] readBytes = Files.readAllBytes(tempFileTo);
+    assertArrayEquals(BLOB_BYTE_CONTENT, readBytes);
+  }
+
+  @Test
+  public void testUploadWithEncryption() throws Exception {
+    String blobName = "test-upload-withEncryption";
+    BlobId blobId = BlobId.of(BUCKET, blobName);
+    BlobInfo blobInfo = BlobInfo.newBuilder(blobId).build();
+
+    ByteArrayInputStream content = new ByteArrayInputStream(BLOB_BYTE_CONTENT);
+    storageOperations.upload(blobInfo, content, Storage.BlobWriteOption.encryptionKey(KEY));
+
+    Blob blob = storage.get(blobId);
+    try {
+      blob.getContent();
+      fail("StorageException was expected");
+    } catch (StorageException e) {
+      String expectedMessage =
+          "The target object is encrypted by a customer-supplied encryption key.";
+      assertTrue(e.getMessage().contains(expectedMessage));
+      assertEquals(400, e.getCode());
+    }
+    byte[] readBytes = blob.getContent(Blob.BlobSourceOption.decryptionKey(KEY));
+    assertArrayEquals(BLOB_BYTE_CONTENT, readBytes);
+  }
 }