Repository: googleapis/java-storage
Pull request #1922 (2023-03-02 21:37:51)

feat: First Pass Implementation of UploadMany
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/java-storage/issues/new/choose) before writing your code!  That way we can discuss the change, evaluate designs, and agree on the general idea
- [ ] Ensure the tests and linter pass
- [ ] Code coverage does not decrease (if any source code was changed)
- [ ] Appropriate docs were updated (if necessary)

Fixes #<issue_number_goes_here> ☕️

If you write sample code, please follow the [samples format](
https://github.com/GoogleCloudPlatform/java-docs-samples/blob/main/SAMPLE_FORMAT.md).


conventional-commit-lint-gcf[bot]: <!-- probot comment [1356997]-->
🤖 I detect that the PR title and the commit message differ and there's only one commit. To use the PR title for the commit history, you can use Github's automerge feature with squashing, or use `automerge` label. Good luck human!

 -- conventional-commit-lint bot
https://conventionalcommits.org/

Patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/DownloadCallable.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/DownloadCallable.java
new file mode 100644
index 00000000..0996bfab
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/DownloadCallable.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2023 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.transfermanager;
+
+public class DownloadCallable {}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/ParallelUploadConfig.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/ParallelUploadConfig.java
index bc715fe3..421a9e68 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/ParallelUploadConfig.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/ParallelUploadConfig.java
@@ -19,6 +19,7 @@ package com.google.cloud.storage.transfermanager;
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.cloud.storage.Storage.BlobTargetOption;
+import com.google.cloud.storage.Storage.BlobWriteOption;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import java.util.List;
@@ -30,17 +31,21 @@ public final class ParallelUploadConfig {
   private final boolean skipIfExists;
   @NonNull private final String prefix;
   @NonNull private final String bucketName;
-  @NonNull private final List<BlobTargetOption> optionsPerRequest;
+  @NonNull private final List<BlobTargetOption> targetOptsPerRequest;
+
+  @NonNull private final List<BlobWriteOption> writeOptsPerRequest;
 
   private ParallelUploadConfig(
       boolean skipIfExists,
       @NonNull String prefix,
       @NonNull String bucketName,
-      @NonNull List<BlobTargetOption> optionsPerRequest) {
+      @NonNull List<BlobTargetOption> targetOptsPerRequest,
+      @NonNull List<BlobWriteOption> writeOptsPerRequest) {
     this.skipIfExists = skipIfExists;
     this.prefix = prefix;
     this.bucketName = bucketName;
-    this.optionsPerRequest = optionsPerRequest;
+    this.targetOptsPerRequest = targetOptsPerRequest;
+    this.writeOptsPerRequest = writeOptsPerRequest;
   }
 
   public boolean isSkipIfExists() {
@@ -55,8 +60,12 @@ public final class ParallelUploadConfig {
     return bucketName;
   }
 
-  public @NonNull List<BlobTargetOption> getOptionsPerRequest() {
-    return optionsPerRequest;
+  public @NonNull List<BlobTargetOption> getTargetOptsPerRequest() {
+    return targetOptsPerRequest;
+  }
+
+  public @NonNull List<BlobWriteOption> getWriteOptsPerRequest() {
+    return writeOptsPerRequest;
   }
 
   @Override
@@ -71,12 +80,12 @@ public final class ParallelUploadConfig {
     return skipIfExists == that.skipIfExists
         && prefix.equals(that.prefix)
         && bucketName.equals(that.bucketName)
-        && optionsPerRequest.equals(that.optionsPerRequest);
+        && targetOptsPerRequest.equals(that.targetOptsPerRequest);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(skipIfExists, prefix, bucketName, optionsPerRequest);
+    return Objects.hash(skipIfExists, prefix, bucketName, targetOptsPerRequest);
   }
 
   @Override
@@ -85,7 +94,7 @@ public final class ParallelUploadConfig {
         .add("skipIfExists", skipIfExists)
         .add("prefix", prefix)
         .add("bucketName", bucketName)
-        .add("optionsPerRequest", optionsPerRequest)
+        .add("optionsPerRequest", targetOptsPerRequest)
         .toString();
   }
 
@@ -100,10 +109,13 @@ public final class ParallelUploadConfig {
     private @NonNull String bucketName;
     private @NonNull List<BlobTargetOption> optionsPerRequest;
 
+    private @NonNull List<BlobWriteOption> writeOptsPerRequest;
+
     private Builder() {
       this.prefix = "";
       this.bucketName = "";
       this.optionsPerRequest = ImmutableList.of();
+      this.writeOptsPerRequest = ImmutableList.of();
     }
 
     public Builder setSkipIfExists(boolean skipIfExists) {
@@ -126,11 +138,18 @@ public final class ParallelUploadConfig {
       return this;
     }
 
+    public Builder setWriteOptsPerRequest(@NonNull List<BlobWriteOption> writeOptsPerRequest) {
+      this.writeOptsPerRequest = writeOptsPerRequest;
+      return this;
+    }
+
     public ParallelUploadConfig build() {
       checkNotNull(prefix);
       checkNotNull(bucketName);
       checkNotNull(optionsPerRequest);
-      return new ParallelUploadConfig(skipIfExists, prefix, bucketName, optionsPerRequest);
+      checkNotNull(writeOptsPerRequest);
+      return new ParallelUploadConfig(
+          skipIfExists, prefix, bucketName, optionsPerRequest, writeOptsPerRequest);
     }
   }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManager.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManager.java
index e871cae4..bc250e6e 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManager.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManager.java
@@ -17,6 +17,7 @@
 package com.google.cloud.storage.transfermanager;
 
 import com.google.cloud.storage.BlobInfo;
+import java.io.IOException;
 import java.nio.file.Path;
 import java.util.List;
 import org.checkerframework.checker.nullness.qual.NonNull;
@@ -24,7 +25,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
 public interface TransferManager {
 
   @NonNull
-  UploadJob uploadFiles(List<Path> files, ParallelUploadConfig opts);
+  UploadJob uploadFiles(List<Path> files, ParallelUploadConfig opts) throws IOException;
 
   @NonNull
   DownloadJob downloadBlobs(List<BlobInfo> blobs, ParallelDownloadConfig opts);
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerConfig.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerConfig.java
index 7ec5f80d..b3f6a6b4 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerConfig.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerConfig.java
@@ -16,22 +16,26 @@
 
 package com.google.cloud.storage.transfermanager;
 
+import com.google.cloud.storage.StorageOptions;
 import com.google.common.base.MoreObjects;
 import java.util.Objects;
 
-public class TransferManagerConfig {
+public final class TransferManagerConfig {
   private final int maxWorkers;
   private final int perWorkerBufferSize;
   private final boolean allowChunking;
 
-  // Getting stuff in for implementation bits
-  // getService to get Storage instance
-  // private final StorageOptions storageOptions;
+  private final StorageOptions storageOptions;
 
-  private TransferManagerConfig(int maxWorkers, int perWorkerBufferSize, boolean allowChunking) {
+  TransferManagerConfig(
+      int maxWorkers,
+      int perWorkerBufferSize,
+      boolean allowChunking,
+      StorageOptions storageOptions) {
     this.maxWorkers = maxWorkers;
     this.perWorkerBufferSize = perWorkerBufferSize;
     this.allowChunking = allowChunking;
+    this.storageOptions = storageOptions;
   }
 
   public int getMaxWorkers() {
@@ -46,23 +50,28 @@ public class TransferManagerConfig {
     return allowChunking;
   }
 
+  public StorageOptions getStorageOptions() {
+    return storageOptions;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (this == o) {
       return true;
     }
-    if (o == null || getClass() != o.getClass()) {
+    if (!(o instanceof TransferManagerConfig)) {
       return false;
     }
     TransferManagerConfig that = (TransferManagerConfig) o;
     return maxWorkers == that.maxWorkers
         && perWorkerBufferSize == that.perWorkerBufferSize
-        && allowChunking == that.allowChunking;
+        && allowChunking == that.allowChunking
+        && Objects.equals(storageOptions, that.storageOptions);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(maxWorkers, perWorkerBufferSize, allowChunking);
+    return Objects.hash(maxWorkers, perWorkerBufferSize, allowChunking, storageOptions);
   }
 
   @Override
@@ -71,6 +80,7 @@ public class TransferManagerConfig {
         .add("maxWorkers", maxWorkers)
         .add("perWorkerBufferSize", perWorkerBufferSize)
         .add("allowChunking", allowChunking)
+        .add("storageOptions", storageOptions)
         .toString();
   }
 
@@ -78,18 +88,21 @@ public class TransferManagerConfig {
     return new Builder();
   }
 
-  static class Builder {
+  public static class Builder {
 
     private int maxWorkers;
     private int perWorkerBufferSize;
     private boolean allowChunking;
 
+    private StorageOptions storageOptions;
+
     private Builder() {
       // TODO: add default values
       //  bufferSize tbd?
       this.perWorkerBufferSize = 16 * 1024 * 1024;
       this.maxWorkers = 2 * Runtime.getRuntime().availableProcessors();
       this.allowChunking = false;
+      this.storageOptions = StorageOptions.getDefaultInstance();
     }
 
     public Builder setMaxWorkers(int maxWorkers) {
@@ -107,8 +120,13 @@ public class TransferManagerConfig {
       return this;
     }
 
+    public void setStorageOptions(StorageOptions storageOptions) {
+      this.storageOptions = storageOptions;
+    }
+
     public TransferManagerConfig build() {
-      return new TransferManagerConfig(maxWorkers, perWorkerBufferSize, allowChunking);
+      return new TransferManagerConfig(
+          maxWorkers, perWorkerBufferSize, allowChunking, storageOptions);
     }
   }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerImpl.java
index 34ff5ddb..849929dc 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerImpl.java
@@ -16,4 +16,46 @@
 
 package com.google.cloud.storage.transfermanager;
 
-public class TransferManagerImpl {}
+import com.google.cloud.storage.BlobInfo;
+import com.google.common.collect.ImmutableList;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public final class TransferManagerImpl implements TransferManager {
+
+  private final TransferManagerConfig transferManagerConfig;
+  private final ExecutorService executor;
+
+  public TransferManagerImpl(TransferManagerConfig transferManagerConfig) {
+    this.transferManagerConfig = transferManagerConfig;
+    this.executor = Executors.newFixedThreadPool(transferManagerConfig.getMaxWorkers());
+  }
+
+  @Override
+  public @NonNull UploadJob uploadFiles(List<Path> files, ParallelUploadConfig opts) {
+    List<Future<UploadResult>> uploadTasks = new ArrayList<>();
+    for (Path file : files) {
+      if (Files.isDirectory(file)) throw new IllegalStateException("Directories are not supported");
+      String blobName = TransferManagerUtils.createBlobName(opts, file);
+      BlobInfo blobInfo = BlobInfo.newBuilder(opts.getBucketName(), blobName).build();
+      // TODO: Apply opts per request
+      UploadCallable callable = new UploadCallable(executor, transferManagerConfig, blobInfo, file);
+      uploadTasks.add(executor.submit(callable));
+    }
+    return UploadJob.newBuilder()
+        .setParallelUploadConfig(opts)
+        .setUploadResponses(ImmutableList.copyOf(uploadTasks))
+        .build();
+  }
+
+  @Override
+  public @NonNull DownloadJob downloadBlobs(List<BlobInfo> blobs, ParallelDownloadConfig opts) {
+    return null;
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerUtils.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerUtils.java
new file mode 100644
index 00000000..4f55fbb9
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerUtils.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023 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.transfermanager;
+
+import java.nio.file.Path;
+
+public class TransferManagerUtils {
+
+  public static String createBlobName(ParallelUploadConfig config, Path file) {
+    if (config.getPrefix().isEmpty()) {
+      return file.toString();
+    } else {
+      return config.getPrefix().concat(file.toString());
+    }
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/UploadCallable.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/UploadCallable.java
new file mode 100644
index 00000000..0a61a9bc
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/UploadCallable.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2023 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.transfermanager;
+
+import com.google.cloud.WriteChannel;
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.PackagePrivateMethodWorkarounds;
+import com.google.cloud.storage.StorageException;
+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;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+
+public class UploadCallable implements Callable<UploadResult> {
+  private final ExecutorService executor;
+  private final TransferManagerConfig transferManagerConfig;
+
+  private final BlobInfo originalBlob;
+
+  private final Path sourceFile;
+
+  public UploadCallable(
+      ExecutorService executor,
+      TransferManagerConfig transferManagerConfig,
+      BlobInfo originalBlob,
+      Path sourceFile) {
+    this.executor = executor;
+    this.transferManagerConfig = transferManagerConfig;
+    this.originalBlob = originalBlob;
+    this.sourceFile = sourceFile;
+  }
+
+  public UploadResult call() throws Exception {
+    // TODO: Check for chunking
+    return uploadWithoutChunking();
+  }
+
+  private UploadResult uploadWithoutChunking() throws IOException {
+    Optional<BlobInfo> newBlob;
+    WriteChannel wc = transferManagerConfig.getStorageOptions().getService().writer(originalBlob);
+    try {
+      InputStream inputStream = Files.newInputStream(sourceFile);
+      uploadHelper(Channels.newChannel(inputStream), wc);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    } finally {
+      wc.close();
+    }
+    newBlob = PackagePrivateMethodWorkarounds.maybeGetBlobInfoFunction().apply(wc);
+    UploadResult result =
+        UploadResult.newBuilder(originalBlob, TransferStatus.SUCCESS)
+            .setUploadedBlob(newBlob.get())
+            .build();
+    return result;
+  }
+
+  private void uploadHelper(ReadableByteChannel reader, WriteChannel writer) throws IOException {
+    ByteBuffer buffer = ByteBuffer.allocate(transferManagerConfig.getPerWorkerBufferSize());
+    writer.setChunkSize(transferManagerConfig.getPerWorkerBufferSize());
+
+    while (reader.read(buffer) >= 0) {
+      buffer.flip();
+      writer.write(buffer);
+      buffer.clear();
+    }
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/UploadJob.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/UploadJob.java
index ffe8d61f..009b0bc5 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/UploadJob.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/UploadJob.java
@@ -22,36 +22,25 @@ import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import java.util.List;
 import java.util.Objects;
+import java.util.concurrent.Future;
 import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
 import org.checkerframework.checker.nullness.qual.NonNull;
 
 public final class UploadJob {
 
-  @NonNull private final List<UploadResult> successResponses;
-  @NonNull private final List<UploadResult> failedResponses;
+  @NonNull private final List<Future<UploadResult>> uploadResponses;
+
   @NonNull private final ParallelUploadConfig parallelUploadConfig;
-  private final boolean anyFailed;
 
   private UploadJob(
-      @NonNull List<UploadResult> successResponses,
-      @NonNull List<UploadResult> failedResponses,
+      @NonNull List<Future<UploadResult>> successResponses,
       @NonNull ParallelUploadConfig parallelUploadConfig) {
-    this.successResponses = successResponses;
-    this.failedResponses = failedResponses;
+    this.uploadResponses = successResponses;
     this.parallelUploadConfig = parallelUploadConfig;
-    this.anyFailed = !this.failedResponses.isEmpty();
-  }
-
-  public List<UploadResult> getSuccessResponses() {
-    return successResponses;
-  }
-
-  public List<UploadResult> getFailedResponses() {
-    return failedResponses;
   }
 
-  public boolean isAnyFailed() {
-    return anyFailed;
+  public List<Future<UploadResult>> getUploadResponses() {
+    return uploadResponses;
   }
 
   public ParallelUploadConfig getParallelUploadConfig() {
@@ -67,24 +56,20 @@ public final class UploadJob {
       return false;
     }
     UploadJob uploadJob = (UploadJob) o;
-    return anyFailed == uploadJob.anyFailed
-        && successResponses.equals(uploadJob.successResponses)
-        && failedResponses.equals(uploadJob.failedResponses)
+    return uploadResponses.equals(uploadJob.uploadResponses)
         && parallelUploadConfig.equals(uploadJob.parallelUploadConfig);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(successResponses, failedResponses, parallelUploadConfig, anyFailed);
+    return Objects.hash(uploadResponses, parallelUploadConfig);
   }
 
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
-        .add("successResponses", successResponses)
-        .add("failedResponses", failedResponses)
+        .add("successResponses", uploadResponses)
         .add("parallelUploadConfig", parallelUploadConfig)
-        .add("anyFailed", anyFailed)
         .toString();
   }
 
@@ -94,23 +79,16 @@ public final class UploadJob {
 
   public static final class Builder {
 
-    private @NonNull List<UploadResult> successResponses;
-    private @NonNull List<UploadResult> failedResponses;
+    private @NonNull List<Future<UploadResult>> uploadResponses;
 
     private @MonotonicNonNull ParallelUploadConfig parallelUploadConfig;
 
     private Builder() {
-      this.successResponses = ImmutableList.of();
-      this.failedResponses = ImmutableList.of();
-    }
-
-    public Builder setSuccessResponses(@NonNull List<UploadResult> successResponses) {
-      this.successResponses = ImmutableList.copyOf(successResponses);
-      return this;
+      this.uploadResponses = ImmutableList.of();
     }
 
-    public Builder setFailedResponses(@NonNull List<UploadResult> failedResponses) {
-      this.failedResponses = ImmutableList.copyOf(failedResponses);
+    public Builder setUploadResponses(@NonNull List<Future<UploadResult>> uploadResponses) {
+      this.uploadResponses = ImmutableList.copyOf(uploadResponses);
       return this;
     }
 
@@ -120,10 +98,9 @@ public final class UploadJob {
     }
 
     public UploadJob build() {
-      checkNotNull(successResponses);
-      checkNotNull(failedResponses);
+      checkNotNull(uploadResponses);
       checkNotNull(parallelUploadConfig);
-      return new UploadJob(successResponses, failedResponses, parallelUploadConfig);
+      return new UploadJob(uploadResponses, parallelUploadConfig);
     }
   }
 }


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/PackagePrivateMethodWorkarounds.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/PackagePrivateMethodWorkarounds.java
similarity index 89%
rename from google-cloud-storage/src/test/java/com/google/cloud/storage/PackagePrivateMethodWorkarounds.java
rename to google-cloud-storage/src/main/java/com/google/cloud/storage/PackagePrivateMethodWorkarounds.java
index 45af1ad5..f9736944 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/PackagePrivateMethodWorkarounds.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/PackagePrivateMethodWorkarounds.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 Google LLC
+ * Copyright 2023 Google LLC
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -70,6 +70,17 @@ public final class PackagePrivateMethodWorkarounds {
     };
   }
 
+  public static Function<WriteChannel, Optional<BlobInfo>> maybeGetBlobInfoFunction() {
+    return writeChannel -> {
+      Optional<StorageObject> so = maybeGetStorageObjectFunction().apply(writeChannel);
+      if (so.isPresent()) {
+        return Optional.of(Conversions.apiary().blobInfo().decode(so.get()));
+      } else {
+        return Optional.empty();
+      }
+    };
+  }
+
   public static ApiFuture<BlobInfo> getBlobInfoFromReadChannelFunction(ReadChannel c) {
     if (c instanceof StorageReadChannel) {
       StorageReadChannel src = (StorageReadChannel) c;
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITTransferManagerTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITTransferManagerTest.java
new file mode 100644
index 00000000..65436c28
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITTransferManagerTest.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2023 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.it;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.DataGenerator;
+import com.google.cloud.storage.TmpFile;
+import com.google.cloud.storage.TransportCompatibility.Transport;
+import com.google.cloud.storage.it.runner.StorageITRunner;
+import com.google.cloud.storage.it.runner.annotations.Backend;
+import com.google.cloud.storage.it.runner.annotations.CrossRun;
+import com.google.cloud.storage.it.runner.annotations.Inject;
+import com.google.cloud.storage.it.runner.registry.Generator;
+import com.google.cloud.storage.transfermanager.ParallelUploadConfig;
+import com.google.cloud.storage.transfermanager.TransferManager;
+import com.google.cloud.storage.transfermanager.TransferManagerConfig;
+import com.google.cloud.storage.transfermanager.TransferManagerImpl;
+import com.google.cloud.storage.transfermanager.UploadJob;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+@RunWith(StorageITRunner.class)
+@CrossRun(
+    transports = {Transport.HTTP},
+    backends = {Backend.PROD})
+public class ITTransferManagerTest {
+  @Inject public BucketInfo bucket;
+  @Inject public Generator generator;
+
+  @Rule public final TemporaryFolder tmpDir = new TemporaryFolder();
+
+  private Path baseDir;
+  private static final int objectContentSize = 64;
+
+  @Before
+  public void setUp() throws Exception {
+    baseDir = tmpDir.getRoot().toPath();
+  }
+
+  @Test
+  public void uploadFiles() throws IOException {
+    TransferManagerConfig config =
+        TransferManagerConfig.newBuilder().setAllowChunking(false).setMaxWorkers(1).build();
+    TransferManager transferManager = new TransferManagerImpl(config);
+    try (TmpFile tmpFile = DataGenerator.base64Characters().tempFile(baseDir, objectContentSize);
+        TmpFile tmpFile1 = DataGenerator.base64Characters().tempFile(baseDir, objectContentSize);
+        TmpFile tmpFile2 = DataGenerator.base64Characters().tempFile(baseDir, objectContentSize)) {
+      List<Path> files =
+          ImmutableList.of(tmpFile.getPath(), tmpFile1.getPath(), tmpFile2.getPath());
+      String bucketName = bucket.getName();
+      ParallelUploadConfig parallelUploadConfig =
+          ParallelUploadConfig.newBuilder().setBucketName(bucketName).build();
+      UploadJob job = transferManager.uploadFiles(files, parallelUploadConfig);
+      assertThat(job.getUploadResponses()).hasSize(3);
+    }
+  }
+
+  @Test
+  public void downloadBlobs() {}
+}