Skip to content

Commit 86e9ae8

Browse files
authored
feat(transfer-manager): add ParallelUploadConfig.Builder#setUploadBlobInfoFactory (#2936)
When uploading a file from the files system, there are scenarios when a job would need to customize the actual BlobInfo used to upload to GCS. Add the new UploadBlobInfoFactory which allows a user to produce their own BlobInfo instance given the bucketName and fileName. When producing the BlobInfo the application can also customize other metadata fields. A few convenience adapter methods are available in UploadBlobInfoFactory to simplify common operations. Fixes #2638
1 parent e8ba858 commit 86e9ae8

File tree

6 files changed

+357
-22
lines changed

6 files changed

+357
-22
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.storage.transfermanager;
18+
19+
public final class BucketNameMismatchException extends RuntimeException {
20+
21+
public BucketNameMismatchException(String actual, String expected) {
22+
super(
23+
String.format(
24+
"Bucket name in produced BlobInfo did not match bucket name from config. (%s != %s)",
25+
actual, expected));
26+
}
27+
}

‎google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/ParallelUploadConfig.java

Lines changed: 146 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818

1919
import static com.google.common.base.Preconditions.checkNotNull;
2020

21+
import com.google.cloud.storage.BlobInfo;
2122
import com.google.cloud.storage.Storage.BlobWriteOption;
2223
import com.google.common.base.MoreObjects;
2324
import com.google.common.collect.ImmutableList;
2425
import java.util.List;
2526
import java.util.Objects;
27+
import java.util.function.Function;
2628
import org.checkerframework.checker.nullness.qual.NonNull;
2729

2830
/**
@@ -33,19 +35,19 @@
3335
public final class ParallelUploadConfig {
3436

3537
private final boolean skipIfExists;
36-
@NonNull private final String prefix;
3738
@NonNull private final String bucketName;
39+
@NonNull private final UploadBlobInfoFactory uploadBlobInfoFactory;
3840

3941
@NonNull private final List<BlobWriteOption> writeOptsPerRequest;
4042

4143
private ParallelUploadConfig(
4244
boolean skipIfExists,
43-
@NonNull String prefix,
4445
@NonNull String bucketName,
46+
@NonNull UploadBlobInfoFactory uploadBlobInfoFactory,
4547
@NonNull List<BlobWriteOption> writeOptsPerRequest) {
4648
this.skipIfExists = skipIfExists;
47-
this.prefix = prefix;
4849
this.bucketName = bucketName;
50+
this.uploadBlobInfoFactory = uploadBlobInfoFactory;
4951
this.writeOptsPerRequest = applySkipIfExists(skipIfExists, writeOptsPerRequest);
5052
}
5153

@@ -63,9 +65,26 @@ public boolean isSkipIfExists() {
6365
* A common prefix that will be applied to all object paths in the destination bucket
6466
*
6567
* @see Builder#setPrefix(String)
68+
* @see Builder#setUploadBlobInfoFactory(UploadBlobInfoFactory)
69+
* @see UploadBlobInfoFactory#prefixObjectNames(String)
6670
*/
6771
public @NonNull String getPrefix() {
68-
return prefix;
72+
if (uploadBlobInfoFactory instanceof PrefixObjectNames) {
73+
PrefixObjectNames prefixObjectNames = (PrefixObjectNames) uploadBlobInfoFactory;
74+
return prefixObjectNames.prefix;
75+
}
76+
return "";
77+
}
78+
79+
/**
80+
* The {@link UploadBlobInfoFactory} which will be used to produce a {@link BlobInfo}s based on a
81+
* provided bucket name and file name.
82+
*
83+
* @see Builder#setUploadBlobInfoFactory(UploadBlobInfoFactory)
84+
* @since 2.49.0
85+
*/
86+
public @NonNull UploadBlobInfoFactory getUploadBlobInfoFactory() {
87+
return uploadBlobInfoFactory;
6988
}
7089

7190
/**
@@ -96,22 +115,22 @@ public boolean equals(Object o) {
96115
}
97116
ParallelUploadConfig that = (ParallelUploadConfig) o;
98117
return skipIfExists == that.skipIfExists
99-
&& prefix.equals(that.prefix)
100118
&& bucketName.equals(that.bucketName)
119+
&& uploadBlobInfoFactory.equals(that.uploadBlobInfoFactory)
101120
&& writeOptsPerRequest.equals(that.writeOptsPerRequest);
102121
}
103122

104123
@Override
105124
public int hashCode() {
106-
return Objects.hash(skipIfExists, prefix, bucketName, writeOptsPerRequest);
125+
return Objects.hash(skipIfExists, bucketName, uploadBlobInfoFactory, writeOptsPerRequest);
107126
}
108127

109128
@Override
110129
public String toString() {
111130
return MoreObjects.toStringHelper(this)
112131
.add("skipIfExists", skipIfExists)
113-
.add("prefix", prefix)
114132
.add("bucketName", bucketName)
133+
.add("uploadBlobInfoFactory", uploadBlobInfoFactory)
115134
.add("writeOptsPerRequest", writeOptsPerRequest)
116135
.toString();
117136
}
@@ -137,13 +156,13 @@ private static List<BlobWriteOption> applySkipIfExists(
137156
public static final class Builder {
138157

139158
private boolean skipIfExists;
140-
private @NonNull String prefix;
141159
private @NonNull String bucketName;
160+
private @NonNull UploadBlobInfoFactory uploadBlobInfoFactory;
142161
private @NonNull List<BlobWriteOption> writeOptsPerRequest;
143162

144163
private Builder() {
145-
this.prefix = "";
146164
this.bucketName = "";
165+
this.uploadBlobInfoFactory = UploadBlobInfoFactory.defaultInstance();
147166
this.writeOptsPerRequest = ImmutableList.of();
148167
}
149168

@@ -162,11 +181,37 @@ public Builder setSkipIfExists(boolean skipIfExists) {
162181
/**
163182
* Sets a common prefix that will be applied to all object paths in the destination bucket.
164183
*
184+
* <p><i>NOTE</i>: this method and {@link #setUploadBlobInfoFactory(UploadBlobInfoFactory)} are
185+
* mutually exclusive, and last invocation "wins".
186+
*
165187
* @return the builder instance with the value for prefix modified.
166188
* @see ParallelUploadConfig#getPrefix()
189+
* @see ParallelUploadConfig.Builder#setUploadBlobInfoFactory(UploadBlobInfoFactory)
190+
* @see UploadBlobInfoFactory#prefixObjectNames(String)
167191
*/
168192
public Builder setPrefix(@NonNull String prefix) {
169-
this.prefix = prefix;
193+
this.uploadBlobInfoFactory = UploadBlobInfoFactory.prefixObjectNames(prefix);
194+
return this;
195+
}
196+
197+
/**
198+
* Sets a {@link UploadBlobInfoFactory} which can be used to produce a custom BlobInfo based on
199+
* a provided bucket name and file name.
200+
*
201+
* <p>The bucket name in the returned BlobInfo MUST be equal to the value provided to {@link
202+
* #setBucketName(String)}, if not that upload will fail with a {@link
203+
* TransferStatus#FAILED_TO_START} and a {@link BucketNameMismatchException}.
204+
*
205+
* <p><i>NOTE</i>: this method and {@link #setPrefix(String)} are mutually exclusive, and last
206+
* invocation "wins".
207+
*
208+
* @return the builder instance with the value for uploadBlobInfoFactory modified.
209+
* @see ParallelUploadConfig#getPrefix()
210+
* @see ParallelUploadConfig#getUploadBlobInfoFactory()
211+
* @since 2.49.0
212+
*/
213+
public Builder setUploadBlobInfoFactory(@NonNull UploadBlobInfoFactory uploadBlobInfoFactory) {
214+
this.uploadBlobInfoFactory = uploadBlobInfoFactory;
170215
return this;
171216
}
172217

@@ -199,10 +244,99 @@ public Builder setWriteOptsPerRequest(@NonNull List<BlobWriteOption> writeOptsPe
199244
* @return {@link ParallelUploadConfig}
200245
*/
201246
public ParallelUploadConfig build() {
202-
checkNotNull(prefix);
203247
checkNotNull(bucketName);
248+
checkNotNull(uploadBlobInfoFactory);
204249
checkNotNull(writeOptsPerRequest);
205-
return new ParallelUploadConfig(skipIfExists, prefix, bucketName, writeOptsPerRequest);
250+
return new ParallelUploadConfig(
251+
skipIfExists, bucketName, uploadBlobInfoFactory, writeOptsPerRequest);
252+
}
253+
}
254+
255+
public interface UploadBlobInfoFactory {
256+
257+
/**
258+
* Method to produce a {@link BlobInfo} to be used for the upload to Cloud Storage.
259+
*
260+
* <p>The bucket name in the returned BlobInfo MUST be equal to the value provided to the {@link
261+
* ParallelUploadConfig.Builder#setBucketName(String)}, if not that upload will fail with a
262+
* {@link TransferStatus#FAILED_TO_START} and a {@link BucketNameMismatchException}.
263+
*
264+
* @param bucketName The name of the bucket to be uploaded to. The value provided here will be
265+
* the value from {@link ParallelUploadConfig#getBucketName()}.
266+
* @param fileName The String representation of the absolute path of the file to be uploaded
267+
* @return The instance of {@link BlobInfo} that should be used to upload the file to Cloud
268+
* Storage.
269+
*/
270+
BlobInfo apply(String bucketName, String fileName);
271+
272+
/**
273+
* Adapter factory to provide the same semantics as if using {@link Builder#setPrefix(String)}
274+
*/
275+
static UploadBlobInfoFactory prefixObjectNames(String prefix) {
276+
return new PrefixObjectNames(prefix);
277+
}
278+
279+
/** The default instance which applies not modification to the provided {@code fileName} */
280+
static UploadBlobInfoFactory defaultInstance() {
281+
return DefaultUploadBlobInfoFactory.INSTANCE;
282+
}
283+
284+
/**
285+
* Convenience method to "lift" a {@link Function} that transforms the file name to an {@link
286+
* UploadBlobInfoFactory}
287+
*/
288+
static UploadBlobInfoFactory transformFileName(Function<String, String> fileNameTransformer) {
289+
return (b, f) -> BlobInfo.newBuilder(b, fileNameTransformer.apply(f)).build();
290+
}
291+
}
292+
293+
private static final class DefaultUploadBlobInfoFactory implements UploadBlobInfoFactory {
294+
private static final DefaultUploadBlobInfoFactory INSTANCE = new DefaultUploadBlobInfoFactory();
295+
296+
private DefaultUploadBlobInfoFactory() {}
297+
298+
@Override
299+
public BlobInfo apply(String bucketName, String fileName) {
300+
return BlobInfo.newBuilder(bucketName, fileName).build();
301+
}
302+
}
303+
304+
private static final class PrefixObjectNames implements UploadBlobInfoFactory {
305+
private final String prefix;
306+
307+
private PrefixObjectNames(String prefix) {
308+
this.prefix = prefix;
309+
}
310+
311+
@Override
312+
public BlobInfo apply(String bucketName, String fileName) {
313+
String separator = "";
314+
if (!fileName.startsWith("/")) {
315+
separator = "/";
316+
}
317+
return BlobInfo.newBuilder(bucketName, prefix + separator + fileName).build();
318+
}
319+
320+
@Override
321+
public boolean equals(Object o) {
322+
if (this == o) {
323+
return true;
324+
}
325+
if (!(o instanceof PrefixObjectNames)) {
326+
return false;
327+
}
328+
PrefixObjectNames that = (PrefixObjectNames) o;
329+
return Objects.equals(prefix, that.prefix);
330+
}
331+
332+
@Override
333+
public int hashCode() {
334+
return Objects.hashCode(prefix);
335+
}
336+
337+
@Override
338+
public String toString() {
339+
return MoreObjects.toStringHelper(this).add("prefix", prefix).toString();
206340
}
207341
}
208342
}

‎google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerImpl.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,17 @@ public void close() throws Exception {
110110
List<ApiFuture<UploadResult>> uploadTasks = new ArrayList<>();
111111
for (Path file : files) {
112112
if (Files.isDirectory(file)) throw new IllegalStateException("Directories are not supported");
113-
String blobName = TransferManagerUtils.createBlobName(config, file);
114-
BlobInfo blobInfo = BlobInfo.newBuilder(config.getBucketName(), blobName).build();
113+
String bucketName = config.getBucketName();
114+
BlobInfo blobInfo =
115+
config.getUploadBlobInfoFactory().apply(bucketName, file.toAbsolutePath().toString());
116+
if (!blobInfo.getBucket().equals(bucketName)) {
117+
uploadTasks.add(
118+
ApiFutures.immediateFuture(
119+
UploadResult.newBuilder(blobInfo, TransferStatus.FAILED_TO_START)
120+
.setException(new BucketNameMismatchException(blobInfo.getBucket(), bucketName))
121+
.build()));
122+
continue;
123+
}
115124
if (transferManagerConfig.isAllowParallelCompositeUpload()
116125
&& qos.parallelCompositeUpload(Files.size(file))) {
117126
ParallelCompositeUploadCallable callable =

‎google-cloud-storage/src/main/java/com/google/cloud/storage/transfermanager/TransferManagerUtils.java

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ final class TransferManagerUtils {
2626

2727
private TransferManagerUtils() {}
2828

29-
static String createBlobName(ParallelUploadConfig config, Path file) {
30-
if (config.getPrefix().isEmpty()) {
31-
return file.toString();
32-
} else {
33-
return config.getPrefix().concat(file.toString());
34-
}
35-
}
36-
3729
static Path createDestPath(ParallelDownloadConfig config, BlobInfo originalBlob) {
3830
Path newPath =
3931
config

0 commit comments

Comments
 (0)