From ecd5162dec640008bb51ef4c7e66935c24a893fa Mon Sep 17 00:00:00 2001
From: "release-please[bot]"
<55107282+release-please[bot]@users.noreply.github.com>
Date: Tue, 21 Oct 2025 15:43:33 -0400
Subject: [PATCH 01/14] chore(main): release 2.59.1-SNAPSHOT (#3357)
Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com>
---
gapic-google-cloud-storage-v2/pom.xml | 4 ++--
google-cloud-storage-bom/pom.xml | 16 ++++++++--------
google-cloud-storage-control/pom.xml | 4 ++--
google-cloud-storage/pom.xml | 4 ++--
grpc-google-cloud-storage-control-v2/pom.xml | 4 ++--
grpc-google-cloud-storage-v2/pom.xml | 4 ++--
pom.xml | 16 ++++++++--------
proto-google-cloud-storage-control-v2/pom.xml | 4 ++--
proto-google-cloud-storage-v2/pom.xml | 4 ++--
samples/snapshot/pom.xml | 6 +++---
storage-shared-benchmarking/pom.xml | 4 ++--
versions.txt | 14 +++++++-------
12 files changed, 42 insertions(+), 42 deletions(-)
diff --git a/gapic-google-cloud-storage-v2/pom.xml b/gapic-google-cloud-storage-v2/pom.xml
index d391400bd1..eb10795fd7 100644
--- a/gapic-google-cloud-storage-v2/pom.xml
+++ b/gapic-google-cloud-storage-v2/pom.xml
@@ -4,13 +4,13 @@
4.0.0com.google.api.grpcgapic-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTgapic-google-cloud-storage-v2GRPC library for gapic-google-cloud-storage-v2com.google.cloudgoogle-cloud-storage-parent
- 2.59.0
+ 2.59.1-SNAPSHOT
diff --git a/google-cloud-storage-bom/pom.xml b/google-cloud-storage-bom/pom.xml
index 4d5e1276d2..f11503a51c 100644
--- a/google-cloud-storage-bom/pom.xml
+++ b/google-cloud-storage-bom/pom.xml
@@ -19,7 +19,7 @@
4.0.0com.google.cloudgoogle-cloud-storage-bom
- 2.59.0
+ 2.59.1-SNAPSHOTpomcom.google.cloud
@@ -69,37 +69,37 @@
com.google.cloudgoogle-cloud-storage
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcgapic-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcgrpc-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcproto-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.cloudgoogle-cloud-storage-control
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcgrpc-google-cloud-storage-control-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcproto-google-cloud-storage-control-v2
- 2.59.0
+ 2.59.1-SNAPSHOT
diff --git a/google-cloud-storage-control/pom.xml b/google-cloud-storage-control/pom.xml
index 932f08ab6a..d85264ab04 100644
--- a/google-cloud-storage-control/pom.xml
+++ b/google-cloud-storage-control/pom.xml
@@ -5,13 +5,13 @@
4.0.0com.google.cloudgoogle-cloud-storage-control
- 2.59.0
+ 2.59.1-SNAPSHOTgoogle-cloud-storage-controlGRPC library for google-cloud-storage-controlcom.google.cloudgoogle-cloud-storage-parent
- 2.59.0
+ 2.59.1-SNAPSHOT
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index bfa8010af4..718170e533 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -2,7 +2,7 @@
4.0.0google-cloud-storage
- 2.59.0
+ 2.59.1-SNAPSHOTjarGoogle Cloud Storagehttps://github.com/googleapis/java-storage
@@ -12,7 +12,7 @@
com.google.cloudgoogle-cloud-storage-parent
- 2.59.0
+ 2.59.1-SNAPSHOTgoogle-cloud-storage
diff --git a/grpc-google-cloud-storage-control-v2/pom.xml b/grpc-google-cloud-storage-control-v2/pom.xml
index 9d25ee46c5..3e6ac29825 100644
--- a/grpc-google-cloud-storage-control-v2/pom.xml
+++ b/grpc-google-cloud-storage-control-v2/pom.xml
@@ -4,13 +4,13 @@
4.0.0com.google.api.grpcgrpc-google-cloud-storage-control-v2
- 2.59.0
+ 2.59.1-SNAPSHOTgrpc-google-cloud-storage-control-v2GRPC library for google-cloud-storagecom.google.cloudgoogle-cloud-storage-parent
- 2.59.0
+ 2.59.1-SNAPSHOT
diff --git a/grpc-google-cloud-storage-v2/pom.xml b/grpc-google-cloud-storage-v2/pom.xml
index 9d5fb64685..8dd4c9e1e2 100644
--- a/grpc-google-cloud-storage-v2/pom.xml
+++ b/grpc-google-cloud-storage-v2/pom.xml
@@ -4,13 +4,13 @@
4.0.0com.google.api.grpcgrpc-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTgrpc-google-cloud-storage-v2GRPC library for grpc-google-cloud-storage-v2com.google.cloudgoogle-cloud-storage-parent
- 2.59.0
+ 2.59.1-SNAPSHOT
diff --git a/pom.xml b/pom.xml
index a9ef213685..0d423e33b0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
com.google.cloudgoogle-cloud-storage-parentpom
- 2.59.0
+ 2.59.1-SNAPSHOTStorage Parenthttps://github.com/googleapis/java-storage
@@ -82,7 +82,7 @@
com.google.cloudgoogle-cloud-storage
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.apis
@@ -104,32 +104,32 @@
com.google.api.grpcproto-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcgrpc-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcgapic-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcgrpc-google-cloud-storage-control-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.api.grpcproto-google-cloud-storage-control-v2
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.cloudgoogle-cloud-storage-control
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.cloud
diff --git a/proto-google-cloud-storage-control-v2/pom.xml b/proto-google-cloud-storage-control-v2/pom.xml
index a258e6b249..edf1e6e394 100644
--- a/proto-google-cloud-storage-control-v2/pom.xml
+++ b/proto-google-cloud-storage-control-v2/pom.xml
@@ -4,13 +4,13 @@
4.0.0com.google.api.grpcproto-google-cloud-storage-control-v2
- 2.59.0
+ 2.59.1-SNAPSHOTproto-google-cloud-storage-control-v2Proto library for proto-google-cloud-storage-control-v2com.google.cloudgoogle-cloud-storage-parent
- 2.59.0
+ 2.59.1-SNAPSHOT
diff --git a/proto-google-cloud-storage-v2/pom.xml b/proto-google-cloud-storage-v2/pom.xml
index 7788c504e4..44c2c2eb35 100644
--- a/proto-google-cloud-storage-v2/pom.xml
+++ b/proto-google-cloud-storage-v2/pom.xml
@@ -4,13 +4,13 @@
4.0.0com.google.api.grpcproto-google-cloud-storage-v2
- 2.59.0
+ 2.59.1-SNAPSHOTproto-google-cloud-storage-v2PROTO library for proto-google-cloud-storage-v2com.google.cloudgoogle-cloud-storage-parent
- 2.59.0
+ 2.59.1-SNAPSHOT
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index 2ac6fe76f2..0c5d92eed0 100644
--- a/samples/snapshot/pom.xml
+++ b/samples/snapshot/pom.xml
@@ -28,12 +28,12 @@
com.google.cloudgoogle-cloud-storage
- 2.59.0
+ 2.59.1-SNAPSHOTcom.google.cloudgoogle-cloud-storage-control
- 2.59.0
+ 2.59.1-SNAPSHOTcompile
@@ -70,7 +70,7 @@
com.google.cloudgoogle-cloud-storage
- 2.59.0
+ 2.59.1-SNAPSHOTteststest
diff --git a/storage-shared-benchmarking/pom.xml b/storage-shared-benchmarking/pom.xml
index 2379fec2f2..ffd421ac14 100644
--- a/storage-shared-benchmarking/pom.xml
+++ b/storage-shared-benchmarking/pom.xml
@@ -10,7 +10,7 @@
com.google.cloudgoogle-cloud-storage-parent
- 2.59.0
+ 2.59.1-SNAPSHOT
@@ -31,7 +31,7 @@
com.google.cloudgoogle-cloud-storage
- 2.59.0
+ 2.59.1-SNAPSHOTtests
diff --git a/versions.txt b/versions.txt
index e73ee03edb..47e29cbca1 100644
--- a/versions.txt
+++ b/versions.txt
@@ -1,10 +1,10 @@
# Format:
# module:released-version:current-version
-google-cloud-storage:2.59.0:2.59.0
-gapic-google-cloud-storage-v2:2.59.0:2.59.0
-grpc-google-cloud-storage-v2:2.59.0:2.59.0
-proto-google-cloud-storage-v2:2.59.0:2.59.0
-google-cloud-storage-control:2.59.0:2.59.0
-proto-google-cloud-storage-control-v2:2.59.0:2.59.0
-grpc-google-cloud-storage-control-v2:2.59.0:2.59.0
+google-cloud-storage:2.59.0:2.59.1-SNAPSHOT
+gapic-google-cloud-storage-v2:2.59.0:2.59.1-SNAPSHOT
+grpc-google-cloud-storage-v2:2.59.0:2.59.1-SNAPSHOT
+proto-google-cloud-storage-v2:2.59.0:2.59.1-SNAPSHOT
+google-cloud-storage-control:2.59.0:2.59.1-SNAPSHOT
+proto-google-cloud-storage-control-v2:2.59.0:2.59.1-SNAPSHOT
+grpc-google-cloud-storage-control-v2:2.59.0:2.59.1-SNAPSHOT
From e56e45a658fcd55b10635df4ebc776ed6689de8b Mon Sep 17 00:00:00 2001
From: Mend Renovate
Date: Tue, 21 Oct 2025 21:13:50 +0100
Subject: [PATCH 02/14] chore(deps): update storage release dependencies to
v2.59.0 (#3358)
---
samples/install-without-bom/pom.xml | 6 +++---
samples/snippets/pom.xml | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index b77249bb47..83c0d078c8 100644
--- a/samples/install-without-bom/pom.xml
+++ b/samples/install-without-bom/pom.xml
@@ -30,12 +30,12 @@
com.google.cloudgoogle-cloud-storage
- 2.58.1
+ 2.59.0com.google.cloudgoogle-cloud-storage-control
- 2.58.1
+ 2.59.0
@@ -78,7 +78,7 @@
com.google.cloudgoogle-cloud-storage
- 2.58.1
+ 2.59.0teststest
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index 36544329ce..035e1b3cdd 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -99,7 +99,7 @@
com.google.cloudgoogle-cloud-storage
- 2.58.1
+ 2.59.0teststest
From c1a8968799c1cf5a970fe9f303adccdad0a117c8 Mon Sep 17 00:00:00 2001
From: cloud-java-bot <122572305+cloud-java-bot@users.noreply.github.com>
Date: Wed, 22 Oct 2025 14:53:36 -0400
Subject: [PATCH 03/14] chore: Update generation configuration at Wed Oct 22
02:29:18 UTC 2025 (#3342)
---
.github/workflows/hermetic_library_generation.yaml | 2 +-
.kokoro/presubmit/graalvm-native-a.cfg | 2 +-
.kokoro/presubmit/graalvm-native-b.cfg | 2 +-
.kokoro/presubmit/graalvm-native-c.cfg | 2 +-
README.md | 6 +++---
generation_config.yaml | 6 +++---
6 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/hermetic_library_generation.yaml b/.github/workflows/hermetic_library_generation.yaml
index a2011e0037..9872c68489 100644
--- a/.github/workflows/hermetic_library_generation.yaml
+++ b/.github/workflows/hermetic_library_generation.yaml
@@ -43,7 +43,7 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.CLOUD_JAVA_BOT_TOKEN }}
- - uses: googleapis/sdk-platform-java/.github/scripts@v2.62.3
+ - uses: googleapis/sdk-platform-java/.github/scripts@v2.63.0
if: env.SHOULD_RUN == 'true'
with:
base_ref: ${{ github.base_ref }}
diff --git a/.kokoro/presubmit/graalvm-native-a.cfg b/.kokoro/presubmit/graalvm-native-a.cfg
index b7567eeb7d..55c5543e26 100644
--- a/.kokoro/presubmit/graalvm-native-a.cfg
+++ b/.kokoro/presubmit/graalvm-native-a.cfg
@@ -3,7 +3,7 @@
# Configure the docker image for kokoro-trampoline.
env_vars: {
key: "TRAMPOLINE_IMAGE"
- value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_a:3.52.3" # {x-version-update:google-cloud-shared-dependencies:current}
+ value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_a:3.53.0" # {x-version-update:google-cloud-shared-dependencies:current}
}
env_vars: {
diff --git a/.kokoro/presubmit/graalvm-native-b.cfg b/.kokoro/presubmit/graalvm-native-b.cfg
index c7205f0abd..5c981b9848 100644
--- a/.kokoro/presubmit/graalvm-native-b.cfg
+++ b/.kokoro/presubmit/graalvm-native-b.cfg
@@ -3,7 +3,7 @@
# Configure the docker image for kokoro-trampoline.
env_vars: {
key: "TRAMPOLINE_IMAGE"
- value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_b:3.52.3" # {x-version-update:google-cloud-shared-dependencies:current}
+ value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_b:3.53.0" # {x-version-update:google-cloud-shared-dependencies:current}
}
env_vars: {
diff --git a/.kokoro/presubmit/graalvm-native-c.cfg b/.kokoro/presubmit/graalvm-native-c.cfg
index f6ab8976a5..f2032499df 100644
--- a/.kokoro/presubmit/graalvm-native-c.cfg
+++ b/.kokoro/presubmit/graalvm-native-c.cfg
@@ -3,7 +3,7 @@
# Configure the docker image for kokoro-trampoline.
env_vars: {
key: "TRAMPOLINE_IMAGE"
- value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_c:3.52.3" # {x-version-update:google-cloud-shared-dependencies:current}
+ value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_c:3.53.0" # {x-version-update:google-cloud-shared-dependencies:current}
}
env_vars: {
diff --git a/README.md b/README.md
index 78d10b82bd..eb5054d7d4 100644
--- a/README.md
+++ b/README.md
@@ -46,12 +46,12 @@ If you are using Maven without the BOM, add this to your dependencies:
com.google.cloudgoogle-cloud-storage
- 2.58.1
+ 2.59.0com.google.cloudgoogle-cloud-storage-control
- 2.58.1
+ 2.59.0
```
@@ -59,7 +59,7 @@ If you are using Maven without the BOM, add this to your dependencies:
If you are using Gradle 5.x or later, add this to your dependencies:
```Groovy
-implementation platform('com.google.cloud:libraries-bom:26.69.0')
+implementation platform('com.google.cloud:libraries-bom:26.70.0')
implementation 'com.google.cloud:google-cloud-storage'
```
diff --git a/generation_config.yaml b/generation_config.yaml
index f9ad815233..e5d15d30a6 100644
--- a/generation_config.yaml
+++ b/generation_config.yaml
@@ -1,6 +1,6 @@
-gapic_generator_version: 2.62.3
-googleapis_commitish: 7b2b58ff4fb3eee3c0923af35fdee90134fabe3b
-libraries_bom_version: 26.69.0
+gapic_generator_version: 2.63.0
+googleapis_commitish: 94ccdfe4519e0ba817bd33aa22eb9c64f88a6874
+libraries_bom_version: 26.70.0
libraries:
- api_shortname: storage
name_pretty: Cloud Storage
From f0787cc213d2a15b6c8769a5d2030b1f60f7a45e Mon Sep 17 00:00:00 2001
From: Mend Renovate
Date: Wed, 22 Oct 2025 20:35:25 +0100
Subject: [PATCH 04/14] build(deps): update dependency
org.codehaus.mojo:exec-maven-plugin to v3.6.2 (#3353)
---
google-cloud-storage/pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index 718170e533..91ce32083d 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -487,7 +487,7 @@
org.codehaus.mojoexec-maven-plugin
- 3.6.1
+ 3.6.2
From 763be91bd8532b4fc8c3a3c2248509c0c5256086 Mon Sep 17 00:00:00 2001
From: Mend Renovate
Date: Wed, 22 Oct 2025 20:58:27 +0100
Subject: [PATCH 05/14] test(deps): update cross product test dependencies
(#3355)
---
google-cloud-storage/pom.xml | 6 +++---
pom.xml | 2 +-
samples/install-without-bom/pom.xml | 4 ++--
samples/snapshot/pom.xml | 4 ++--
samples/snippets/pom.xml | 4 ++--
5 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index 91ce32083d..a83bc576fc 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -16,7 +16,7 @@
google-cloud-storage
- 1.124.0
+ 1.125.0
@@ -239,14 +239,14 @@
com.google.api.grpcproto-google-cloud-kms-v1
- 0.171.0
+ 0.172.0testcom.google.cloudgoogle-cloud-kms
- 2.80.0
+ 2.81.0test
diff --git a/pom.xml b/pom.xml
index 0d423e33b0..593f316fd7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -92,7 +92,7 @@
com.google.cloudgoogle-cloud-pubsub
- 1.142.0
+ 1.143.0test
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index 83c0d078c8..1da04a0133 100644
--- a/samples/install-without-bom/pom.xml
+++ b/samples/install-without-bom/pom.xml
@@ -66,13 +66,13 @@
com.google.cloudgoogle-cloud-pubsub
- 1.142.0
+ 1.143.0testcom.google.cloudgoogle-cloud-kms
- 2.80.0
+ 2.81.0test
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index 0c5d92eed0..1b8a94203f 100644
--- a/samples/snapshot/pom.xml
+++ b/samples/snapshot/pom.xml
@@ -58,13 +58,13 @@
com.google.cloudgoogle-cloud-pubsub
- 1.142.0
+ 1.143.0testcom.google.cloudgoogle-cloud-kms
- 2.80.0
+ 2.81.0test
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index 035e1b3cdd..bfa3500c1a 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -76,13 +76,13 @@
com.google.cloudgoogle-cloud-pubsub
- 1.142.0
+ 1.143.0testcom.google.cloudgoogle-cloud-kms
- 2.80.0
+ 2.81.0test
From ebf5e6d30d8dc197ab388a70cc0d465c0f740496 Mon Sep 17 00:00:00 2001
From: Luwei Ge
Date: Tue, 28 Oct 2025 16:20:07 -0700
Subject: [PATCH 06/14] fix: add new system property
(com.google.cloud.storage.grpc.bound_token) to allow disabling bound token
use with grpc (#3365)
Co-authored-by: BenWhitehead
---
.../google/cloud/storage/GrpcStorageOptions.java | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
index 54dd12519d..1a6726b9c0 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
@@ -134,6 +134,10 @@ public final class GrpcStorageOptions extends StorageOptions
private static final String GCS_SCOPE = "https://www.googleapis.com/auth/devstorage.full_control";
private static final Set SCOPES = ImmutableSet.of(GCS_SCOPE);
private static final String DEFAULT_HOST = "https://storage.googleapis.com";
+ // If true, disable the bound-token-by-default feature for DirectPath.
+ private static final boolean DIRECT_PATH_BOUND_TOKEN_DISABLED =
+ Boolean.parseBoolean(
+ System.getProperty("com.google.cloud.storage.grpc.bound_token", "false"));
private final GrpcRetryAlgorithmManager retryAlgorithmManager;
private final java.time.Duration terminationAwaitDuration;
@@ -318,10 +322,12 @@ private Tuple> resolveSettingsAndOpts() throw
InstantiatingGrpcChannelProvider.newBuilder()
.setEndpoint(endpoint)
.setAllowNonDefaultServiceAccount(true)
- .setAttemptDirectPath(attemptDirectPath)
- .setAllowHardBoundTokenTypes(
- Collections.singletonList(
- InstantiatingGrpcChannelProvider.HardBoundTokenTypes.ALTS));
+ .setAttemptDirectPath(attemptDirectPath);
+
+ if (!DIRECT_PATH_BOUND_TOKEN_DISABLED) {
+ channelProviderBuilder.setAllowHardBoundTokenTypes(
+ Collections.singletonList(InstantiatingGrpcChannelProvider.HardBoundTokenTypes.ALTS));
+ }
if (!NoopGrpcInterceptorProvider.INSTANCE.equals(grpcInterceptorProvider)) {
channelProviderBuilder.setInterceptorProvider(grpcInterceptorProvider);
From cfc92f7fd53735b7612e33e7bb405d2e038e89c9 Mon Sep 17 00:00:00 2001
From: Mend Renovate
Date: Tue, 28 Oct 2025 23:22:00 +0000
Subject: [PATCH 07/14] test(deps): update
gcr.io/cloud-devrel-public-resources/storage-testbench docker tag to v0.56.0
(#3367)
---
.../com/google/cloud/storage/it/runner/registry/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/google-cloud-storage/src/test/resources/com/google/cloud/storage/it/runner/registry/Dockerfile b/google-cloud-storage/src/test/resources/com/google/cloud/storage/it/runner/registry/Dockerfile
index 8d979f05ee..6ba8de349c 100644
--- a/google-cloud-storage/src/test/resources/com/google/cloud/storage/it/runner/registry/Dockerfile
+++ b/google-cloud-storage/src/test/resources/com/google/cloud/storage/it/runner/registry/Dockerfile
@@ -1 +1 @@
-FROM gcr.io/cloud-devrel-public-resources/storage-testbench:v0.55.0
+FROM gcr.io/cloud-devrel-public-resources/storage-testbench:v0.56.0
From 03e626140ee2d43b8433838ff4b54ecc1b4c6bab Mon Sep 17 00:00:00 2001
From: cloud-java-bot <122572305+cloud-java-bot@users.noreply.github.com>
Date: Wed, 29 Oct 2025 12:15:33 -0400
Subject: [PATCH 08/14] chore: Update generation configuration at Wed Oct 29
02:30:41 UTC 2025 (#3362)
---
README.md | 2 +-
generation_config.yaml | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index eb5054d7d4..4fa6de6833 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ If you are using Maven without the BOM, add this to your dependencies:
If you are using Gradle 5.x or later, add this to your dependencies:
```Groovy
-implementation platform('com.google.cloud:libraries-bom:26.70.0')
+implementation platform('com.google.cloud:libraries-bom:26.71.0')
implementation 'com.google.cloud:google-cloud-storage'
```
diff --git a/generation_config.yaml b/generation_config.yaml
index e5d15d30a6..80b7630256 100644
--- a/generation_config.yaml
+++ b/generation_config.yaml
@@ -1,6 +1,6 @@
gapic_generator_version: 2.63.0
-googleapis_commitish: 94ccdfe4519e0ba817bd33aa22eb9c64f88a6874
-libraries_bom_version: 26.70.0
+googleapis_commitish: 72e7439c8e7e9986cf1865e337fc7c64ca5bda1f
+libraries_bom_version: 26.71.0
libraries:
- api_shortname: storage
name_pretty: Cloud Storage
From 87c9c8125cb97230c52e465012b5069d31744a79 Mon Sep 17 00:00:00 2001
From: Nidhi
Date: Fri, 31 Oct 2025 03:38:53 +0530
Subject: [PATCH 09/14] fix: Ignore tests failing due to public access
prevention policy (#3374)
---
.../src/test/java/com/example/storage/ITBucketSnippets.java | 2 ++
.../src/test/java/com/example/storage/ITObjectSnippets.java | 3 +++
2 files changed, 5 insertions(+)
diff --git a/samples/snippets/src/test/java/com/example/storage/ITBucketSnippets.java b/samples/snippets/src/test/java/com/example/storage/ITBucketSnippets.java
index 051bd05e23..6d649ef965 100644
--- a/samples/snippets/src/test/java/com/example/storage/ITBucketSnippets.java
+++ b/samples/snippets/src/test/java/com/example/storage/ITBucketSnippets.java
@@ -101,6 +101,7 @@
import org.junit.After;
import org.junit.AfterClass;
import org.junit.BeforeClass;
+import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
@@ -425,6 +426,7 @@ public void testAddListRemoveBucketIamMembers() throws Throwable {
.build());
}
+ @Ignore("TODO(b/456381873): Test fails in CI due to project's public access prevention policy.")
@Test
public void testMakeBucketPublic() throws Throwable {
MakeBucketPublic.makeBucketPublic(PROJECT_ID, BUCKET);
diff --git a/samples/snippets/src/test/java/com/example/storage/ITObjectSnippets.java b/samples/snippets/src/test/java/com/example/storage/ITObjectSnippets.java
index 28ceeb3081..c55b2041a1 100644
--- a/samples/snippets/src/test/java/com/example/storage/ITObjectSnippets.java
+++ b/samples/snippets/src/test/java/com/example/storage/ITObjectSnippets.java
@@ -92,6 +92,7 @@
import java.util.Random;
import javax.net.ssl.HttpsURLConnection;
import org.junit.Assert;
+import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -167,6 +168,7 @@ public void testDownloadObjectIntoMemory() throws IOException {
assertThat(snippetOutput).contains("The contents of " + objectName);
}
+ @Ignore("TODO(b/456381873): Test fails in CI due to project's public access prevention policy.")
@Test
public void testDownloadPublicObject() throws Exception {
try (TemporaryBucket tmpBucket =
@@ -436,6 +438,7 @@ public void testV4SignedURLs() throws IOException {
}
}
+ @Ignore("TODO(b/456381873): Test fails in CI due to project's public access prevention policy.")
@Test
public void testMakeObjectPublic() {
String aclBlob = generator.randomObjectName();
From 160fa9af7aa492373a9d9b40f65a6c56d7cab5ef Mon Sep 17 00:00:00 2001
From: Shreyas Sinha
Date: Mon, 3 Nov 2025 21:06:41 +0000
Subject: [PATCH 10/14] chore: mpu client preview merge train
BEGIN_NESTED_COMMIT
BEGIN_COMMIT_OVERRIDE
feat: add preview MultipartUploadClient#createMultipartUpload #3356
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
feat: add preview MultipartUploadClient#listParts #3359
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
feat: add preview MultipartUploadClient#abortMultipartUpload #3361
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
feat: add preview MultipartUploadClient#uploadPart #3375
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
feat: add preview MultipartUploadClient#completeMultipartUpload #3372
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
feat: add preview MultipartUploadSettings
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
END_COMMIT_OVERRIDE
END_NESTED_COMMIT
Other changes:
1. chore: refactor retrier creation from HttpStorageOptions to StorageOptions #3350
2. chore: refactorings for CreateMultipartUpload #3364
3. chore: fix xml parsing of StringEnumValue's so that the enum contract is not broken #3377
4. chore: add PredefinedAcl#xmlEntry
Co-authored-by: BenWhitehead
---
google-cloud-storage/pom.xml | 16 +
.../cloud/storage/ChecksumResponseParser.java | 65 ++
.../storage/HttpRetryAlgorithmManager.java | 4 +
.../cloud/storage/HttpStorageOptions.java | 9 +-
.../cloud/storage/MultipartUploadClient.java | 119 +++
.../storage/MultipartUploadClientImpl.java | 103 +++
.../MultipartUploadHttpRequestManager.java | 276 ++++++
.../storage/MultipartUploadSettings.java | 54 ++
.../com/google/cloud/storage/RequestBody.java | 70 ++
.../cloud/storage/RewindableContent.java | 60 ++
.../com/google/cloud/storage/Storage.java | 24 +-
.../google/cloud/storage/StorageOptions.java | 9 +
.../java/com/google/cloud/storage/Utils.java | 5 +
.../google/cloud/storage/XmlObjectParser.java | 143 +++
.../model/AbortMultipartUploadRequest.java | 145 +++
.../model/AbortMultipartUploadResponse.java | 27 +
.../model/CompleteMultipartUploadRequest.java | 205 +++++
.../CompleteMultipartUploadResponse.java | 247 +++++
.../model/CompletedMultipartUpload.java | 123 +++
.../multipartupload/model/CompletedPart.java | 120 +++
.../model/CreateMultipartUploadRequest.java | 399 +++++++++
.../model/CreateMultipartUploadResponse.java | 188 ++++
.../model/ListPartsRequest.java | 237 +++++
.../model/ListPartsResponse.java | 385 ++++++++
.../multipartupload/model/ObjectLockMode.java | 91 ++
.../storage/multipartupload/model/Part.java | 217 +++++
.../model/UploadPartRequest.java | 206 +++++
.../model/UploadPartResponse.java | 144 +++
.../storage/ChecksumResponseParserTest.java | 102 +++
...MultipartUploadHttpRequestManagerTest.java | 843 ++++++++++++++++++
.../cloud/storage/XmlObjectParserTest.java | 181 ++++
.../it/ITMultipartUploadClientTest.java | 418 +++++++++
32 files changed, 5218 insertions(+), 17 deletions(-)
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/ChecksumResponseParser.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClient.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClientImpl.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadHttpRequestManager.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadSettings.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/RequestBody.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/XmlObjectParser.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/AbortMultipartUploadRequest.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/AbortMultipartUploadResponse.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompleteMultipartUploadRequest.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompleteMultipartUploadResponse.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompletedMultipartUpload.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompletedPart.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadRequest.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadResponse.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ListPartsRequest.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ListPartsResponse.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ObjectLockMode.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/Part.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/UploadPartRequest.java
create mode 100644 google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/UploadPartResponse.java
create mode 100644 google-cloud-storage/src/test/java/com/google/cloud/storage/ChecksumResponseParserTest.java
create mode 100644 google-cloud-storage/src/test/java/com/google/cloud/storage/ITMultipartUploadHttpRequestManagerTest.java
create mode 100644 google-cloud-storage/src/test/java/com/google/cloud/storage/XmlObjectParserTest.java
create mode 100644 google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITMultipartUploadClientTest.java
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index a83bc576fc..54492aff0b 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -19,6 +19,22 @@
1.125.0
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-xml
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ com.google.guavaguava
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ChecksumResponseParser.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ChecksumResponseParser.java
new file mode 100644
index 0000000000..4d32cb42e8
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ChecksumResponseParser.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2025 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.api.client.http.HttpResponse;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.UploadPartResponse;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/** A utility class to parse {@link HttpResponse} and create a {@link UploadPartResponse}. */
+final class ChecksumResponseParser {
+
+ private ChecksumResponseParser() {}
+
+ static UploadPartResponse parseUploadResponse(HttpResponse response) {
+ String eTag = response.getHeaders().getETag();
+ Map hashes = extractHashesFromHeader(response);
+ return UploadPartResponse.builder().eTag(eTag).md5(hashes.get("md5")).build();
+ }
+
+ static CompleteMultipartUploadResponse parseCompleteResponse(HttpResponse response)
+ throws IOException {
+ Map hashes = extractHashesFromHeader(response);
+ CompleteMultipartUploadResponse completeMpuResponse =
+ response.parseAs(CompleteMultipartUploadResponse.class);
+ return CompleteMultipartUploadResponse.builder()
+ .location(completeMpuResponse.location())
+ .bucket(completeMpuResponse.bucket())
+ .key(completeMpuResponse.key())
+ .etag(completeMpuResponse.etag())
+ .crc32c(hashes.get("crc32c"))
+ .build();
+ }
+
+ static Map extractHashesFromHeader(HttpResponse response) {
+ return Optional.ofNullable(response.getHeaders().getFirstHeaderStringValue("x-goog-hash"))
+ .map(
+ h ->
+ Arrays.stream(h.split(","))
+ .map(s -> s.trim().split("=", 2))
+ .filter(a -> a.length == 2)
+ .filter(a -> "crc32c".equalsIgnoreCase(a[0]) || "md5".equalsIgnoreCase(a[0]))
+ .collect(Collectors.toMap(a -> a[0].toLowerCase(), a -> a[1], (v1, v2) -> v1)))
+ .orElse(Collections.emptyMap());
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpRetryAlgorithmManager.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpRetryAlgorithmManager.java
index a2564860c2..3db42e3ac3 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpRetryAlgorithmManager.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpRetryAlgorithmManager.java
@@ -50,6 +50,10 @@ ResultRetryAlgorithm> idempotent() {
return retryStrategy.getIdempotentHandler();
}
+ ResultRetryAlgorithm> nonIdempotent() {
+ return retryStrategy.getNonidempotentHandler();
+ }
+
public ResultRetryAlgorithm> getForBucketAclCreate(
BucketAccessControl pb, Map optionsMap) {
return retryStrategy.getNonidempotentHandler();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java
index b1400bcb62..dac8a010cd 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java
@@ -32,7 +32,6 @@
import com.google.cloud.http.HttpTransportOptions;
import com.google.cloud.spi.ServiceRpcFactory;
import com.google.cloud.storage.BlobWriteSessionConfig.WriterFactory;
-import com.google.cloud.storage.Retrying.DefaultRetrier;
import com.google.cloud.storage.Retrying.HttpRetrier;
import com.google.cloud.storage.Retrying.RetryingDependencies;
import com.google.cloud.storage.Storage.BlobWriteOption;
@@ -409,13 +408,7 @@ public Storage create(StorageOptions options) {
WriterFactory factory = blobWriteSessionConfig.createFactory(clock);
StorageImpl storage =
new StorageImpl(
- httpStorageOptions,
- factory,
- new HttpRetrier(
- new DefaultRetrier(
- OtelStorageDecorator.retryContextDecorator(otel),
- RetryingDependencies.simple(
- options.getClock(), options.getRetrySettings()))));
+ httpStorageOptions, factory, new HttpRetrier(options.createRetrier()));
return OtelStorageDecorator.decorate(storage, otel, Transport.HTTP);
} catch (IOException e) {
throw new IllegalStateException(
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClient.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClient.java
new file mode 100644
index 0000000000..09a3af01c5
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClient.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2025 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.api.core.BetaApi;
+import com.google.api.core.InternalExtensionOnly;
+import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.ListPartsRequest;
+import com.google.cloud.storage.multipartupload.model.ListPartsResponse;
+import com.google.cloud.storage.multipartupload.model.UploadPartRequest;
+import com.google.cloud.storage.multipartupload.model.UploadPartResponse;
+import java.net.URI;
+
+/**
+ * A client for interacting with Google Cloud Storage's Multipart Upload API.
+ *
+ *
This class is for internal use only and is not intended for public consumption. It provides a
+ * low-level interface for creating and managing multipart uploads.
+ *
+ * @see Multipart Uploads
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+@InternalExtensionOnly
+public abstract class MultipartUploadClient {
+
+ MultipartUploadClient() {}
+
+ /**
+ * Creates a new multipart upload.
+ *
+ * @param request The request object containing the details for creating the multipart upload.
+ * @return A {@link CreateMultipartUploadResponse} object containing the upload ID.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public abstract CreateMultipartUploadResponse createMultipartUpload(
+ CreateMultipartUploadRequest request);
+
+ /**
+ * Lists the parts that have been uploaded for a specific multipart upload.
+ *
+ * @param listPartsRequest The request object containing the details for listing the parts.
+ * @return A {@link ListPartsResponse} object containing the list of parts.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public abstract ListPartsResponse listParts(ListPartsRequest listPartsRequest);
+
+ /**
+ * Aborts a multipart upload.
+ *
+ * @param request The request object containing the details for aborting the multipart upload.
+ * @return An {@link AbortMultipartUploadResponse} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public abstract AbortMultipartUploadResponse abortMultipartUpload(
+ AbortMultipartUploadRequest request);
+
+ /**
+ * Completes a multipart upload.
+ *
+ * @param request The request object containing the details for completing the multipart upload.
+ * @return A {@link CompleteMultipartUploadResponse} object containing information about the
+ * completed upload.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public abstract CompleteMultipartUploadResponse completeMultipartUpload(
+ CompleteMultipartUploadRequest request);
+
+ /**
+ * Uploads a part in a multipart upload.
+ *
+ * @param request The request object containing the details for uploading the part.
+ * @param requestBody The content of the part to upload.
+ * @return An {@link UploadPartResponse} object containing the ETag of the uploaded part.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public abstract UploadPartResponse uploadPart(UploadPartRequest request, RequestBody requestBody);
+
+ /**
+ * Creates a new instance of {@link MultipartUploadClient}.
+ *
+ * @param config The configuration for the client.
+ * @return A new {@link MultipartUploadClient} instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static MultipartUploadClient create(MultipartUploadSettings config) {
+ HttpStorageOptions options = config.getOptions();
+ return new MultipartUploadClientImpl(
+ URI.create(options.getHost()),
+ options.createRetrier(),
+ MultipartUploadHttpRequestManager.createFrom(options),
+ options.getRetryAlgorithmManager());
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClientImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClientImpl.java
new file mode 100644
index 0000000000..00b43c6ba9
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClientImpl.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2025 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.storage.Conversions.Decoder;
+import com.google.cloud.storage.Retrying.Retrier;
+import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.ListPartsRequest;
+import com.google.cloud.storage.multipartupload.model.ListPartsResponse;
+import com.google.cloud.storage.multipartupload.model.UploadPartRequest;
+import com.google.cloud.storage.multipartupload.model.UploadPartResponse;
+import java.net.URI;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * This class is an implementation of {@link MultipartUploadClient} that uses the Google Cloud
+ * Storage XML API to perform multipart uploads.
+ */
+final class MultipartUploadClientImpl extends MultipartUploadClient {
+
+ private final MultipartUploadHttpRequestManager httpRequestManager;
+ private final Retrier retrier;
+ private final URI uri;
+ private final HttpRetryAlgorithmManager retryAlgorithmManager;
+
+ MultipartUploadClientImpl(
+ URI uri,
+ Retrier retrier,
+ MultipartUploadHttpRequestManager multipartUploadHttpRequestManager,
+ HttpRetryAlgorithmManager retryAlgorithmManager) {
+ this.httpRequestManager = multipartUploadHttpRequestManager;
+ this.retrier = retrier;
+ this.uri = uri;
+ this.retryAlgorithmManager = retryAlgorithmManager;
+ }
+
+ @Override
+ public CreateMultipartUploadResponse createMultipartUpload(CreateMultipartUploadRequest request) {
+ return retrier.run(
+ retryAlgorithmManager.nonIdempotent(),
+ () -> httpRequestManager.sendCreateMultipartUploadRequest(uri, request),
+ Decoder.identity());
+ }
+
+ @Override
+ public ListPartsResponse listParts(ListPartsRequest request) {
+
+ return retrier.run(
+ retryAlgorithmManager.idempotent(),
+ () -> httpRequestManager.sendListPartsRequest(uri, request),
+ Decoder.identity());
+ }
+
+ @Override
+ public AbortMultipartUploadResponse abortMultipartUpload(AbortMultipartUploadRequest request) {
+
+ return retrier.run(
+ retryAlgorithmManager.idempotent(),
+ () -> httpRequestManager.sendAbortMultipartUploadRequest(uri, request),
+ Decoder.identity());
+ }
+
+ @Override
+ public CompleteMultipartUploadResponse completeMultipartUpload(
+ CompleteMultipartUploadRequest request) {
+ return retrier.run(
+ retryAlgorithmManager.idempotent(),
+ () -> httpRequestManager.sendCompleteMultipartUploadRequest(uri, request),
+ Decoder.identity());
+ }
+
+ @Override
+ public UploadPartResponse uploadPart(UploadPartRequest request, RequestBody requestBody) {
+ AtomicBoolean dirty = new AtomicBoolean(false);
+ return retrier.run(
+ retryAlgorithmManager.idempotent(),
+ () -> {
+ if (dirty.getAndSet(true)) {
+ requestBody.getContent().rewindTo(0);
+ }
+ return httpRequestManager.sendUploadPartRequest(uri, request, requestBody.getContent());
+ },
+ Decoder.identity());
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadHttpRequestManager.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadHttpRequestManager.java
new file mode 100644
index 0000000000..be3a06730a
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadHttpRequestManager.java
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2025 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 com.google.cloud.storage.Utils.ifNonNull;
+
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import com.google.api.client.http.ByteArrayContent;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpHeaders;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.UriTemplate;
+import com.google.api.client.util.ObjectParser;
+import com.google.api.gax.core.GaxProperties;
+import com.google.api.gax.rpc.FixedHeaderProvider;
+import com.google.api.gax.rpc.HeaderProvider;
+import com.google.api.services.storage.Storage;
+import com.google.cloud.storage.Crc32cValue.Crc32cLengthKnown;
+import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.ListPartsRequest;
+import com.google.cloud.storage.multipartupload.model.ListPartsResponse;
+import com.google.cloud.storage.multipartupload.model.UploadPartRequest;
+import com.google.cloud.storage.multipartupload.model.UploadPartResponse;
+import com.google.common.base.StandardSystemProperty;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+final class MultipartUploadHttpRequestManager {
+
+ private final HttpRequestFactory requestFactory;
+ private final ObjectParser objectParser;
+ private final HeaderProvider headerProvider;
+
+ MultipartUploadHttpRequestManager(
+ HttpRequestFactory requestFactory, ObjectParser objectParser, HeaderProvider headerProvider) {
+ this.requestFactory = requestFactory;
+ this.objectParser = objectParser;
+ this.headerProvider = headerProvider;
+ }
+
+ CreateMultipartUploadResponse sendCreateMultipartUploadRequest(
+ URI uri, CreateMultipartUploadRequest request) throws IOException {
+
+ String createUri =
+ UriTemplate.expand(
+ uri.toString() + "{bucket}/{key}?uploads",
+ ImmutableMap.of("bucket", request.bucket(), "key", request.key()),
+ false);
+
+ HttpRequest httpRequest =
+ requestFactory.buildPostRequest(
+ new GenericUrl(createUri), new ByteArrayContent(request.getContentType(), new byte[0]));
+ httpRequest.getHeaders().putAll(headerProvider.getHeaders());
+ addHeadersForCreateMultipartUpload(request, httpRequest.getHeaders());
+ httpRequest.setParser(objectParser);
+ httpRequest.setThrowExceptionOnExecuteError(true);
+ return httpRequest.execute().parseAs(CreateMultipartUploadResponse.class);
+ }
+
+ ListPartsResponse sendListPartsRequest(URI uri, ListPartsRequest request) throws IOException {
+
+ ImmutableMap.Builder params =
+ ImmutableMap.builder()
+ .put("bucket", request.bucket())
+ .put("key", request.key())
+ .put("uploadId", request.uploadId());
+ if (request.getMaxParts() != null) {
+ params.put("max-parts", request.getMaxParts());
+ }
+ if (request.getPartNumberMarker() != null) {
+ params.put("part-number-marker", request.getPartNumberMarker());
+ }
+
+ String listUri =
+ UriTemplate.expand(
+ uri.toString() + "{bucket}/{key}{?uploadId,max-parts,part-number-marker}",
+ params.build(),
+ false);
+ HttpRequest httpRequest = requestFactory.buildGetRequest(new GenericUrl(listUri));
+ httpRequest.getHeaders().putAll(headerProvider.getHeaders());
+ httpRequest.setParser(objectParser);
+ httpRequest.setThrowExceptionOnExecuteError(true);
+ return httpRequest.execute().parseAs(ListPartsResponse.class);
+ }
+
+ AbortMultipartUploadResponse sendAbortMultipartUploadRequest(
+ URI uri, AbortMultipartUploadRequest request) throws IOException {
+
+ String abortUri =
+ UriTemplate.expand(
+ uri.toString() + "{bucket}/{key}{?uploadId}",
+ ImmutableMap.of(
+ "bucket", request.bucket(), "key", request.key(), "uploadId", request.uploadId()),
+ false);
+
+ HttpRequest httpRequest = requestFactory.buildDeleteRequest(new GenericUrl(abortUri));
+ httpRequest.getHeaders().putAll(headerProvider.getHeaders());
+ httpRequest.setParser(objectParser);
+ httpRequest.setThrowExceptionOnExecuteError(true);
+ return httpRequest.execute().parseAs(AbortMultipartUploadResponse.class);
+ }
+
+ CompleteMultipartUploadResponse sendCompleteMultipartUploadRequest(
+ URI uri, CompleteMultipartUploadRequest request) throws IOException {
+ String completeUri =
+ UriTemplate.expand(
+ uri.toString() + "{bucket}/{key}{?uploadId}",
+ ImmutableMap.of(
+ "bucket", request.bucket(), "key", request.key(), "uploadId", request.uploadId()),
+ false);
+ byte[] bytes = new XmlMapper().writeValueAsBytes(request.multipartUpload());
+ HttpRequest httpRequest =
+ requestFactory.buildPostRequest(
+ new GenericUrl(completeUri), new ByteArrayContent("application/xml", bytes));
+ httpRequest.getHeaders().putAll(headerProvider.getHeaders());
+ @Nullable Crc32cLengthKnown crc32cValue = Hasher.defaultHasher().hash(ByteBuffer.wrap(bytes));
+ addChecksumHeader(crc32cValue, httpRequest.getHeaders());
+ httpRequest.setParser(objectParser);
+ httpRequest.setThrowExceptionOnExecuteError(true);
+ return ChecksumResponseParser.parseCompleteResponse(httpRequest.execute());
+ }
+
+ UploadPartResponse sendUploadPartRequest(
+ URI uri, UploadPartRequest request, RewindableContent rewindableContent) throws IOException {
+ String uploadUri =
+ UriTemplate.expand(
+ uri.toString() + "{bucket}/{key}{?partNumber,uploadId}",
+ ImmutableMap.of(
+ "bucket",
+ request.bucket(),
+ "key",
+ request.key(),
+ "partNumber",
+ request.partNumber(),
+ "uploadId",
+ request.uploadId()),
+ false);
+ HttpRequest httpRequest =
+ requestFactory.buildPutRequest(new GenericUrl(uploadUri), rewindableContent);
+ httpRequest.getHeaders().putAll(headerProvider.getHeaders());
+ addChecksumHeader(rewindableContent.getCrc32c(), httpRequest.getHeaders());
+ httpRequest.setThrowExceptionOnExecuteError(true);
+ return ChecksumResponseParser.parseUploadResponse(httpRequest.execute());
+ }
+
+ @SuppressWarnings("DataFlowIssue")
+ static MultipartUploadHttpRequestManager createFrom(HttpStorageOptions options) {
+ Storage storage = options.getStorageRpcV1().getStorage();
+ ImmutableMap.Builder stableHeaders =
+ ImmutableMap.builder()
+ // http-java-client will automatically append its own version to the user-agent
+ .put("User-Agent", "gcloud-java/" + options.getLibraryVersion())
+ .put(
+ "x-goog-api-client",
+ String.format(
+ "gl-java/%s gccl/%s %s/%s",
+ GaxProperties.getJavaVersion(),
+ options.getLibraryVersion(),
+ formatName(StandardSystemProperty.OS_NAME.value()),
+ formatSemver(StandardSystemProperty.OS_VERSION.value())));
+ ifNonNull(options.getProjectId(), pid -> stableHeaders.put("x-goog-user-project", pid));
+ return new MultipartUploadHttpRequestManager(
+ storage.getRequestFactory(),
+ new XmlObjectParser(new XmlMapper()),
+ options.getMergedHeaderProvider(FixedHeaderProvider.create(stableHeaders.build())));
+ }
+
+ private void addChecksumHeader(@Nullable Crc32cLengthKnown crc32c, HttpHeaders headers) {
+ if (crc32c != null) {
+ headers.put("x-goog-hash", "crc32c=" + Utils.crc32cCodec.encode(crc32c.getValue()));
+ }
+ }
+
+ private void addHeadersForCreateMultipartUpload(
+ CreateMultipartUploadRequest request, HttpHeaders headers) {
+ if (request.getCannedAcl() != null) {
+ headers.put("x-goog-acl", request.getCannedAcl().getXmlEntry());
+ }
+ if (request.getMetadata() != null) {
+ for (Map.Entry entry : request.getMetadata().entrySet()) {
+ if (entry.getKey() != null || entry.getValue() != null) {
+ headers.put("x-goog-meta-" + urlEncode(entry.getKey()), urlEncode(entry.getValue()));
+ }
+ }
+ }
+ if (request.getContentType() != null) {
+ headers.put("Content-Type", request.getContentType());
+ }
+ if (request.getStorageClass() != null) {
+ headers.put("x-goog-storage-class", request.getStorageClass().toString());
+ }
+ if (request.getKmsKeyName() != null && !request.getKmsKeyName().isEmpty()) {
+ headers.put("x-goog-encryption-kms-key-name", request.getKmsKeyName());
+ }
+ if (request.getObjectLockMode() != null) {
+ headers.put("x-goog-object-lock-mode", request.getObjectLockMode().toString());
+ }
+ if (request.getObjectLockRetainUntilDate() != null) {
+ headers.put(
+ "x-goog-object-lock-retain-until-date",
+ Utils.offsetDateTimeRfc3339Codec.encode(request.getObjectLockRetainUntilDate()));
+ }
+ if (request.getCustomTime() != null) {
+ headers.put(
+ "x-goog-custom-time", Utils.offsetDateTimeRfc3339Codec.encode(request.getCustomTime()));
+ }
+ }
+
+ private static String urlEncode(String value) {
+ try {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+ } catch (UnsupportedEncodingException e) {
+ throw new StorageException(0, "Unable to load UTF-8 charset for encoding", e);
+ }
+ }
+
+ /**
+ * copied from
+ * com.google.api.client.googleapis.services.AbstractGoogleClientRequest.ApiClientVersion#formatName(java.lang.String)
+ */
+ private static String formatName(String name) {
+ // Only lowercase letters, digits, and "-" are allowed
+ return name.toLowerCase().replaceAll("[^\\w\\d\\-]", "-");
+ }
+
+ private static String formatSemver(String version) {
+ return formatSemver(version, version);
+ }
+
+ /**
+ * copied from
+ * com.google.api.client.googleapis.services.AbstractGoogleClientRequest.ApiClientVersion#formatSemver(java.lang.String,
+ * java.lang.String)
+ */
+ private static String formatSemver(String version, String defaultValue) {
+ if (version == null) {
+ return null;
+ }
+
+ // Take only the semver version: x.y.z-a_b_c -> x.y.z
+ Matcher m = Pattern.compile("(\\d+\\.\\d+\\.\\d+).*").matcher(version);
+ if (m.find()) {
+ return m.group(1);
+ } else {
+ return defaultValue;
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadSettings.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadSettings.java
new file mode 100644
index 0000000000..a4130929ab
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadSettings.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025 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.api.core.BetaApi;
+
+/**
+ * Settings for configuring the {@link MultipartUploadClient}.
+ *
+ *
This class is for internal use only and is not intended for public consumption.
+ */
+@BetaApi
+public final class MultipartUploadSettings {
+ private final HttpStorageOptions options;
+
+ private MultipartUploadSettings(HttpStorageOptions options) {
+ this.options = options;
+ }
+
+ /**
+ * Returns the {@link HttpStorageOptions} configured for multipart uploads.
+ *
+ * @return The {@link HttpStorageOptions}.
+ */
+ @BetaApi
+ public HttpStorageOptions getOptions() {
+ return options;
+ }
+
+ /**
+ * Creates a new {@code MultipartUploadSettings} instance with the specified {@link
+ * HttpStorageOptions}.
+ *
+ * @param options The {@link HttpStorageOptions} to use.
+ * @return A new {@code MultipartUploadSettings} instance.
+ */
+ @BetaApi
+ public static MultipartUploadSettings of(HttpStorageOptions options) {
+ return new MultipartUploadSettings(options);
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/RequestBody.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/RequestBody.java
new file mode 100644
index 0000000000..a14cb173a5
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/RequestBody.java
@@ -0,0 +1,70 @@
+/*
+ * 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;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalExtensionOnly;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+
+/**
+ * The data of a single {@code UploadPart} in a GCS XML MPU.
+ *
+ *
Instances of this class are thread-safe and immutable.
+ *
+ * @see https://cloud.google.com/storage/docs/multipart-uploads#upload_parts
+ */
+@BetaApi
+@InternalExtensionOnly
+public final class RequestBody {
+
+ private final RewindableContent content;
+
+ private RequestBody(RewindableContent content) {
+ this.content = content;
+ }
+
+ RewindableContent getContent() {
+ return content;
+ }
+
+ /** Create a new empty RequestBody. */
+ @BetaApi
+ public static RequestBody empty() {
+ return new RequestBody(RewindableContent.empty());
+ }
+
+ /** Create a new RequestBody from the given {@link ByteBuffer}s. */
+ @BetaApi
+ public static RequestBody of(ByteBuffer... buffers) {
+ return new RequestBody(RewindableContent.of(buffers));
+ }
+
+ /** Create a new RequestBody from the given {@link ByteBuffer}s. */
+ @BetaApi
+ public static RequestBody of(ByteBuffer[] srcs, int srcsOffset, int srcsLength) {
+ return new RequestBody(RewindableContent.of(srcs, srcsOffset, srcsLength));
+ }
+
+ /** Create a new RequestBody from the given {@link Path}. */
+ @BetaApi
+ public static RequestBody of(Path path) throws IOException {
+ return new RequestBody(RewindableContent.of(path));
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java
index 8d299bfb54..e26d0cf558 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java
@@ -18,6 +18,9 @@
import com.google.api.client.http.AbstractHttpContent;
import com.google.api.client.http.HttpMediaType;
+import com.google.cloud.storage.Crc32cValue.Crc32cLengthKnown;
+import com.google.cloud.storage.Hasher.GuavaHasher;
+import com.google.cloud.storage.Hasher.NoOpHasher;
import com.google.common.base.Preconditions;
import com.google.common.io.ByteStreams;
import java.io.IOException;
@@ -32,6 +35,7 @@
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.Locale;
+import org.checkerframework.checker.nullness.qual.Nullable;
abstract class RewindableContent extends AbstractHttpContent {
@@ -55,6 +59,9 @@ public final boolean retrySupported() {
return false;
}
+ @Nullable
+ abstract Crc32cLengthKnown getCrc32c();
+
static RewindableContent empty() {
return EmptyRewindableContent.INSTANCE;
}
@@ -111,6 +118,11 @@ protected void rewindTo(long offset) {}
@Override
void flagDirty() {}
+
+ @Override
+ @Nullable Crc32cLengthKnown getCrc32c() {
+ return Hasher.defaultHasher().initialValue();
+ }
}
private static final class PathRewindableContent extends RewindableContent {
@@ -165,6 +177,36 @@ long writeTo(GatheringByteChannel gbc) throws IOException {
@Override
void flagDirty() {}
+
+ @Override
+ @Nullable Crc32cLengthKnown getCrc32c() {
+ GuavaHasher hasher;
+ {
+ Hasher defaultHasher = Hasher.defaultHasher();
+ if (defaultHasher instanceof NoOpHasher) {
+ return null;
+ } else {
+ hasher = Hasher.enabled();
+ }
+ }
+ Crc32cLengthKnown cumulative = Crc32cValue.zero();
+
+ int bufferSize = 8192; // 8KiB buffer for reading chunks
+ ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
+
+ try (SeekableByteChannel channel = Files.newByteChannel(path, StandardOpenOption.READ)) {
+ while (channel.read(buffer) != -1) {
+ buffer.flip();
+ if (buffer.hasRemaining()) {
+ cumulative = cumulative.concat(hasher.hash(buffer::duplicate));
+ }
+ buffer.clear();
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to read file for CRC32C calculation: " + path, e);
+ }
+ return cumulative;
+ }
}
private static final class ByteBufferContent extends RewindableContent {
@@ -260,5 +302,23 @@ void rewindTo(long offset) {
void flagDirty() {
this.dirty = true;
}
+
+ @Override
+ @Nullable Crc32cLengthKnown getCrc32c() {
+ GuavaHasher hasher;
+ {
+ Hasher defaultHasher = Hasher.defaultHasher();
+ if (defaultHasher instanceof NoOpHasher) {
+ return null;
+ } else {
+ hasher = Hasher.enabled();
+ }
+ }
+ Crc32cLengthKnown cumulative = Crc32cValue.zero();
+ for (ByteBuffer buffer : buffers) {
+ cumulative = cumulative.concat(hasher.hash(buffer::duplicate));
+ }
+ return cumulative;
+ }
}
}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
index 79e270875d..23e94e09f5 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
@@ -93,24 +93,30 @@ public interface Storage extends Service, AutoCloseable {
@TransportCompatibility({Transport.HTTP, Transport.GRPC})
enum PredefinedAcl {
- AUTHENTICATED_READ("authenticatedRead"),
- ALL_AUTHENTICATED_USERS("allAuthenticatedUsers"),
- PRIVATE("private"),
- PROJECT_PRIVATE("projectPrivate"),
- PUBLIC_READ("publicRead"),
- PUBLIC_READ_WRITE("publicReadWrite"),
- BUCKET_OWNER_READ("bucketOwnerRead"),
- BUCKET_OWNER_FULL_CONTROL("bucketOwnerFullControl");
+ AUTHENTICATED_READ("authenticatedRead", "authenticated-read"),
+ ALL_AUTHENTICATED_USERS("allAuthenticatedUsers", "all-authenticated-users"),
+ PRIVATE("private", "private"),
+ PROJECT_PRIVATE("projectPrivate", "project-private"),
+ PUBLIC_READ("publicRead", "public-read"),
+ PUBLIC_READ_WRITE("publicReadWrite", "public-read-write"),
+ BUCKET_OWNER_READ("bucketOwnerRead", "bucket-owner-read"),
+ BUCKET_OWNER_FULL_CONTROL("bucketOwnerFullControl", "bucket-owner-full-control");
private final String entry;
+ private final String xmlEntry;
- PredefinedAcl(String entry) {
+ PredefinedAcl(String entry, String xmlEntry) {
this.entry = entry;
+ this.xmlEntry = xmlEntry;
}
String getEntry() {
return entry;
}
+
+ String getXmlEntry() {
+ return xmlEntry;
+ }
}
enum BucketField implements FieldSelector, NamedField {
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java
index 4dac2b43ef..723a11dc34 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java
@@ -26,6 +26,9 @@
import com.google.cloud.storage.HttpStorageOptions.HttpStorageDefaults;
import com.google.cloud.storage.HttpStorageOptions.HttpStorageFactory;
import com.google.cloud.storage.HttpStorageOptions.HttpStorageRpcFactory;
+import com.google.cloud.storage.Retrying.DefaultRetrier;
+import com.google.cloud.storage.Retrying.Retrier;
+import com.google.cloud.storage.Retrying.RetryingDependencies;
import com.google.cloud.storage.Storage.BlobWriteOption;
import com.google.cloud.storage.TransportCompatibility.Transport;
import com.google.cloud.storage.spi.StorageRpcFactory;
@@ -68,6 +71,12 @@ public abstract class StorageOptions extends ServiceOptions durationSecondsCodec =
Codec.of(Duration::getSeconds, Duration::ofSeconds);
+ static final Codec offsetDateTimeRfc3339Codec =
+ Codec.of(
+ RFC_3339_DATE_TIME_FORMATTER::format,
+ s -> OffsetDateTime.parse(s, RFC_3339_DATE_TIME_FORMATTER));
+
@VisibleForTesting
static final Codec dateTimeCodec =
Codec.of(
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/XmlObjectParser.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/XmlObjectParser.java
new file mode 100644
index 0000000000..23577bf836
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/XmlObjectParser.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2025 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.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.Version;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.Module;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+import com.fasterxml.jackson.databind.module.SimpleDeserializers;
+import com.fasterxml.jackson.databind.module.SimpleSerializers;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.google.api.client.util.ObjectParser;
+import com.google.cloud.StringEnumValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.lang.reflect.Type;
+import java.nio.charset.Charset;
+import java.util.function.Function;
+
+final class XmlObjectParser implements ObjectParser {
+ private final XmlMapper xmlMapper;
+
+ @VisibleForTesting
+ public XmlObjectParser(XmlMapper xmlMapper) {
+ this.xmlMapper = xmlMapper;
+ this.xmlMapper.registerModule(new JavaTimeModule());
+ // ensure parsing does not fail if any new field is ever added in the future
+ this.xmlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ this.xmlMapper.registerModule(
+ new Module() {
+ @Override
+ public String getModuleName() {
+ return this.getClass().getPackage().getName();
+ }
+
+ @Override
+ public Version version() {
+ return Version.unknownVersion();
+ }
+
+ @Override
+ public void setupModule(SetupContext context) {
+ context.addSerializers(
+ new SimpleSerializers(
+ ImmutableList.of(new StringEnumValueSerializer<>(StorageClass.class))));
+ context.addDeserializers(
+ new SimpleDeserializers(
+ ImmutableMap.of(
+ StorageClass.class,
+ new StringEnumValueDeserializer<>(
+ StorageClass.class, StorageClass::valueOf))));
+ }
+ });
+ }
+
+ @Override
+ public T parseAndClose(InputStream in, Charset charset, Class dataClass)
+ throws IOException {
+ return parseAndClose(new InputStreamReader(in, charset), dataClass);
+ }
+
+ @Override
+ public Object parseAndClose(InputStream in, Charset charset, Type dataType) throws IOException {
+ throw new UnsupportedOperationException(
+ "XmlObjectParse#"
+ + CrossTransportUtils.fmtMethodName(
+ "parseAndClose", InputStream.class, Charset.class, Type.class));
+ }
+
+ @Override
+ public T parseAndClose(Reader reader, Class dataClass) throws IOException {
+ try (Reader r = reader) {
+ return xmlMapper.readValue(r, dataClass);
+ }
+ }
+
+ @Override
+ public Object parseAndClose(Reader reader, Type dataType) throws IOException {
+ throw new UnsupportedOperationException(
+ "XmlObjectParse#"
+ + CrossTransportUtils.fmtMethodName("parseAndClose", Reader.class, Type.class));
+ }
+
+ private static final class StringEnumValueDeserializer
+ extends StdDeserializer {
+
+ private final Function constructor;
+
+ private StringEnumValueDeserializer(Class cl, Function constructor) {
+ super(cl);
+ this.constructor = constructor;
+ }
+
+ @Override
+ public E deserialize(JsonParser p, DeserializationContext ctxt)
+ throws IOException, JacksonException {
+ String s = p.readValueAs(String.class);
+ if (s == null || s.trim().isEmpty()) {
+ return null;
+ }
+ return constructor.apply(s);
+ }
+ }
+
+ private static final class StringEnumValueSerializer
+ extends StdSerializer {
+
+ private StringEnumValueSerializer(Class cl) {
+ super(cl);
+ }
+
+ @Override
+ public void serialize(E value, JsonGenerator gen, SerializerProvider provider)
+ throws IOException {
+ gen.writeString(value.name());
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/AbortMultipartUploadRequest.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/AbortMultipartUploadRequest.java
new file mode 100644
index 0000000000..2da671ff7d
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/AbortMultipartUploadRequest.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.google.api.core.BetaApi;
+
+/**
+ * Represents a request to abort a multipart upload. This request is used to stop an in-progress
+ * multipart upload, deleting any previously uploaded parts.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class AbortMultipartUploadRequest {
+ private final String bucket;
+ private final String key;
+ private final String uploadId;
+
+ private AbortMultipartUploadRequest(Builder builder) {
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.uploadId = builder.uploadId;
+ }
+
+ /**
+ * Returns the name of the bucket in which the multipart upload is stored.
+ *
+ * @return The bucket name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the name of the object that is being uploaded.
+ *
+ * @return The object name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Returns the upload ID of the multipart upload to abort.
+ *
+ * @return The upload ID.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String uploadId() {
+ return uploadId;
+ }
+
+ /**
+ * Returns a new builder for creating {@link AbortMultipartUploadRequest} instances.
+ *
+ * @return A new {@link Builder}.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * A builder for creating {@link AbortMultipartUploadRequest} instances.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static class Builder {
+ private String bucket;
+ private String key;
+ private String uploadId;
+
+ private Builder() {}
+
+ /**
+ * Sets the name of the bucket in which the multipart upload is stored.
+ *
+ * @param bucket The bucket name.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder bucket(String bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
+ /**
+ * Sets the name of the object that is being uploaded.
+ *
+ * @param key The object name.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the upload ID of the multipart upload to abort.
+ *
+ * @param uploadId The upload ID.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder uploadId(String uploadId) {
+ this.uploadId = uploadId;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link AbortMultipartUploadRequest} instance.
+ *
+ * @return A new {@link AbortMultipartUploadRequest}.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public AbortMultipartUploadRequest build() {
+ return new AbortMultipartUploadRequest(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/AbortMultipartUploadResponse.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/AbortMultipartUploadResponse.java
new file mode 100644
index 0000000000..050bb68f73
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/AbortMultipartUploadResponse.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.google.api.core.BetaApi;
+
+/**
+ * Represents a response to an abort multipart upload request. This class is currently empty as the
+ * abort operation does not return any specific data in its response body.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class AbortMultipartUploadResponse {}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompleteMultipartUploadRequest.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompleteMultipartUploadRequest.java
new file mode 100644
index 0000000000..291a10d3af
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompleteMultipartUploadRequest.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.google.api.core.BetaApi;
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
+/**
+ * Represents a request to complete a multipart upload.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class CompleteMultipartUploadRequest {
+
+ private final String bucket;
+ private final String key;
+ private final String uploadId;
+ private final CompletedMultipartUpload multipartUpload;
+
+ private CompleteMultipartUploadRequest(Builder builder) {
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.uploadId = builder.uploadId;
+ this.multipartUpload = builder.multipartUpload;
+ }
+
+ /**
+ * Returns the bucket name.
+ *
+ * @return The bucket name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the object name.
+ *
+ * @return The object name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Returns the upload ID of the multipart upload.
+ *
+ * @return The upload ID.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String uploadId() {
+ return uploadId;
+ }
+
+ /**
+ * Returns the {@link CompletedMultipartUpload} payload for this request.
+ *
+ * @return The {@link CompletedMultipartUpload} payload.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public CompletedMultipartUpload multipartUpload() {
+ return multipartUpload;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof CompleteMultipartUploadRequest)) {
+ return false;
+ }
+ CompleteMultipartUploadRequest that = (CompleteMultipartUploadRequest) o;
+ return Objects.equals(bucket, that.bucket)
+ && Objects.equals(key, that.key)
+ && Objects.equals(uploadId, that.uploadId)
+ && Objects.equals(multipartUpload, that.multipartUpload);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(bucket, key, uploadId, multipartUpload);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("bucket", bucket)
+ .add("key", key)
+ .add("uploadId", uploadId)
+ .add("completedMultipartUpload", multipartUpload)
+ .toString();
+ }
+
+ /**
+ * Creates a new builder for {@link CompleteMultipartUploadRequest}.
+ *
+ * @return A new builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for {@link CompleteMultipartUploadRequest}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static class Builder {
+ private String bucket;
+ private String key;
+ private String uploadId;
+ private CompletedMultipartUpload multipartUpload;
+
+ private Builder() {}
+
+ /**
+ * Sets the bucket name.
+ *
+ * @param bucket The bucket name.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder bucket(String bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
+ /**
+ * Sets the object name.
+ *
+ * @param key The object name.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the upload ID of the multipart upload.
+ *
+ * @param uploadId The upload ID.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder uploadId(String uploadId) {
+ this.uploadId = uploadId;
+ return this;
+ }
+
+ /**
+ * Sets the {@link CompletedMultipartUpload} payload for this request.
+ *
+ * @param completedMultipartUpload The {@link CompletedMultipartUpload} payload.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder multipartUpload(CompletedMultipartUpload completedMultipartUpload) {
+ this.multipartUpload = completedMultipartUpload;
+ return this;
+ }
+
+ /**
+ * Builds the {@link CompleteMultipartUploadRequest} object.
+ *
+ * @return The new {@link CompleteMultipartUploadRequest} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public CompleteMultipartUploadRequest build() {
+ return new CompleteMultipartUploadRequest(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompleteMultipartUploadResponse.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompleteMultipartUploadResponse.java
new file mode 100644
index 0000000000..52d16dad6d
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompleteMultipartUploadResponse.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.google.api.core.BetaApi;
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
+/**
+ * Represents the response from a completed multipart upload.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+@JsonDeserialize(builder = CompleteMultipartUploadResponse.Builder.class)
+public final class CompleteMultipartUploadResponse {
+
+ private final String location;
+ private final String bucket;
+ private final String key;
+ private final String etag;
+ private final String crc32c;
+
+ private CompleteMultipartUploadResponse(Builder builder) {
+ this.location = builder.location;
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.etag = builder.etag;
+ this.crc32c = builder.crc32c;
+ }
+
+ /**
+ * Returns the URL of the completed object.
+ *
+ * @return The URL of the completed object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonProperty("Location")
+ public String location() {
+ return location;
+ }
+
+ /**
+ * Returns the bucket name.
+ *
+ * @return The bucket name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonProperty("Bucket")
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the object name.
+ *
+ * @return The object name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonProperty("Key")
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Returns the ETag of the completed object.
+ *
+ * @return The ETag of the completed object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonProperty("ETag")
+ public String etag() {
+ return etag;
+ }
+
+ /**
+ * Returns the CRC32C checksum of the completed object.
+ *
+ * @return The CRC32C checksum of the completed object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String crc32c() {
+ return crc32c;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof CompleteMultipartUploadResponse)) {
+ return false;
+ }
+ CompleteMultipartUploadResponse that = (CompleteMultipartUploadResponse) o;
+ return Objects.equals(location, that.location)
+ && Objects.equals(bucket, that.bucket)
+ && Objects.equals(key, that.key)
+ && Objects.equals(etag, that.etag)
+ && Objects.equals(crc32c, that.crc32c);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(location, bucket, key, etag, crc32c);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("location", location)
+ .add("bucket", bucket)
+ .add("key", key)
+ .add("etag", etag)
+ .add("crc32c", crc32c)
+ .toString();
+ }
+
+ /**
+ * Creates a new builder for {@link CompleteMultipartUploadResponse}.
+ *
+ * @return A new builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for {@link CompleteMultipartUploadResponse}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonPOJOBuilder(buildMethodName = "build")
+ public static class Builder {
+ private String location;
+ private String bucket;
+ private String key;
+ private String etag;
+ private String crc32c;
+
+ private Builder() {}
+
+ /**
+ * Sets the URL of the completed object.
+ *
+ * @param location The URL of the completed object.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonProperty("Location")
+ public Builder location(String location) {
+ this.location = location;
+ return this;
+ }
+
+ /**
+ * Sets the bucket name.
+ *
+ * @param bucket The bucket name.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonProperty("Bucket")
+ public Builder bucket(String bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
+ /**
+ * Sets the object name.
+ *
+ * @param key The object name.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonProperty("Key")
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the ETag of the completed object.
+ *
+ * @param etag The ETag of the completed object.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @JsonProperty("ETag")
+ public Builder etag(String etag) {
+ this.etag = etag;
+ return this;
+ }
+
+ /**
+ * Sets the CRC32C checksum of the completed object.
+ *
+ * @param crc32c The CRC32C checksum of the completed object.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder crc32c(String crc32c) {
+ this.crc32c = crc32c;
+ return this;
+ }
+
+ /**
+ * Builds the {@link CompleteMultipartUploadResponse} object.
+ *
+ * @return The new {@link CompleteMultipartUploadResponse} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public CompleteMultipartUploadResponse build() {
+ return new CompleteMultipartUploadResponse(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompletedMultipartUpload.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompletedMultipartUpload.java
new file mode 100644
index 0000000000..0d008bf5fc
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompletedMultipartUpload.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+import com.google.api.core.BetaApi;
+import com.google.common.base.MoreObjects;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents the XML payload for a completed multipart upload. This is used in the request body
+ * when completing a multipart upload.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+@JacksonXmlRootElement(localName = "CompleteMultipartUpload")
+public class CompletedMultipartUpload {
+
+ @JacksonXmlElementWrapper(useWrapping = false)
+ @JacksonXmlProperty(localName = "Part")
+ private final List completedPartList;
+
+ private CompletedMultipartUpload(Builder builder) {
+ this.completedPartList = builder.parts;
+ }
+
+ /**
+ * Returns the list of completed parts for this multipart upload.
+ *
+ * @return The list of completed parts.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public List parts() {
+ return completedPartList;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof CompletedMultipartUpload)) {
+ return false;
+ }
+ CompletedMultipartUpload that = (CompletedMultipartUpload) o;
+ return Objects.equals(completedPartList, that.completedPartList);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(completedPartList);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this).add("completedPartList", completedPartList).toString();
+ }
+
+ /**
+ * Creates a new builder for {@link CompletedMultipartUpload}.
+ *
+ * @return A new builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for {@link CompletedMultipartUpload}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static class Builder {
+ private List parts;
+
+ private Builder() {}
+
+ /**
+ * Sets the list of completed parts for the multipart upload.
+ *
+ * @param completedPartList The list of completed parts.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder parts(List completedPartList) {
+ this.parts = completedPartList;
+ return this;
+ }
+
+ /**
+ * Builds the {@link CompletedMultipartUpload} object.
+ *
+ * @return The new {@link CompletedMultipartUpload} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public CompletedMultipartUpload build() {
+ return new CompletedMultipartUpload(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompletedPart.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompletedPart.java
new file mode 100644
index 0000000000..8be7ded883
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CompletedPart.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.google.api.core.BetaApi;
+
+/**
+ * Represents a completed part of a multipart upload.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class CompletedPart {
+
+ @JacksonXmlProperty(localName = "PartNumber")
+ private final int partNumber;
+
+ @JacksonXmlProperty(localName = "ETag")
+ private final String eTag;
+
+ private CompletedPart(int partNumber, String eTag) {
+ this.partNumber = partNumber;
+ this.eTag = eTag;
+ }
+
+ /**
+ * Creates a new builder for {@link CompletedPart}.
+ *
+ * @return A new builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Returns the part number of this completed part.
+ *
+ * @return The part number.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public int partNumber() {
+ return partNumber;
+ }
+
+ /**
+ * Returns the ETag of this completed part.
+ *
+ * @return The ETag.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String eTag() {
+ return eTag;
+ }
+
+ /**
+ * Builder for {@link CompletedPart}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static class Builder {
+ private int partNumber;
+ private String etag;
+
+ /**
+ * Sets the part number of the completed part.
+ *
+ * @param partNumber The part number.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder partNumber(int partNumber) {
+ this.partNumber = partNumber;
+ return this;
+ }
+
+ /**
+ * Sets the ETag of the completed part.
+ *
+ * @param etag The ETag.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder eTag(String etag) {
+ this.etag = etag;
+ return this;
+ }
+
+ /**
+ * Builds the {@link CompletedPart} object.
+ *
+ * @return The new {@link CompletedPart} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public CompletedPart build() {
+ return new CompletedPart(partNumber, etag);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadRequest.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadRequest.java
new file mode 100644
index 0000000000..ae1fa1186c
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadRequest.java
@@ -0,0 +1,399 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.google.api.core.BetaApi;
+import com.google.cloud.storage.Storage.PredefinedAcl;
+import com.google.cloud.storage.StorageClass;
+import com.google.common.base.MoreObjects;
+import java.time.OffsetDateTime;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Represents a request to initiate a multipart upload. This class holds all the necessary
+ * information to create a new multipart upload session.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class CreateMultipartUploadRequest {
+ private final String bucket;
+ private final String key;
+ private final PredefinedAcl cannedAcl;
+ private final String contentType;
+ private final Map metadata;
+ private final StorageClass storageClass;
+ private final OffsetDateTime customTime;
+ private final String kmsKeyName;
+ private final ObjectLockMode objectLockMode;
+ private final OffsetDateTime objectLockRetainUntilDate;
+
+ private CreateMultipartUploadRequest(Builder builder) {
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.cannedAcl = builder.cannedAcl;
+ this.contentType = builder.contentType;
+ this.metadata = builder.metadata;
+ this.storageClass = builder.storageClass;
+ this.customTime = builder.customTime;
+ this.kmsKeyName = builder.kmsKeyName;
+ this.objectLockMode = builder.objectLockMode;
+ this.objectLockRetainUntilDate = builder.objectLockRetainUntilDate;
+ }
+
+ /**
+ * Returns the name of the bucket to which the object is being uploaded.
+ *
+ * @return The bucket name
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the name of the object.
+ *
+ * @see Object Naming
+ * @return The object name
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Returns a canned ACL to apply to the object.
+ *
+ * @return The canned ACL
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public PredefinedAcl getCannedAcl() {
+ return cannedAcl;
+ }
+
+ /**
+ * Returns the MIME type of the data you are uploading.
+ *
+ * @return The Content-Type
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String getContentType() {
+ return contentType;
+ }
+
+ /**
+ * Returns the custom metadata of the object.
+ *
+ * @return The custom metadata
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Map getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * Returns the storage class for the object.
+ *
+ * @return The Storage-Class
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public StorageClass getStorageClass() {
+ return storageClass;
+ }
+
+ /**
+ * Returns a user-specified date and time.
+ *
+ * @return The custom time
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public OffsetDateTime getCustomTime() {
+ return customTime;
+ }
+
+ /**
+ * Returns the customer-managed encryption key to use to encrypt the object.
+ *
+ * @return The Cloud KMS key
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String getKmsKeyName() {
+ return kmsKeyName;
+ }
+
+ /**
+ * Returns the mode of the object's retention configuration.
+ *
+ * @return The object lock mode
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public ObjectLockMode getObjectLockMode() {
+ return objectLockMode;
+ }
+
+ /**
+ * Returns the date that determines the time until which the object is retained as immutable.
+ *
+ * @return The object lock retention until date
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public OffsetDateTime getObjectLockRetainUntilDate() {
+ return objectLockRetainUntilDate;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof CreateMultipartUploadRequest)) {
+ return false;
+ }
+ CreateMultipartUploadRequest that = (CreateMultipartUploadRequest) o;
+ return Objects.equals(bucket, that.bucket)
+ && Objects.equals(key, that.key)
+ && cannedAcl == that.cannedAcl
+ && Objects.equals(contentType, that.contentType)
+ && Objects.equals(metadata, that.metadata)
+ && Objects.equals(storageClass, that.storageClass)
+ && Objects.equals(customTime, that.customTime)
+ && Objects.equals(kmsKeyName, that.kmsKeyName)
+ && objectLockMode == that.objectLockMode
+ && Objects.equals(objectLockRetainUntilDate, that.objectLockRetainUntilDate);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ bucket,
+ key,
+ cannedAcl,
+ contentType,
+ metadata,
+ storageClass,
+ customTime,
+ kmsKeyName,
+ objectLockMode,
+ objectLockRetainUntilDate);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("bucket", bucket)
+ .add("key", key)
+ .add("cannedAcl", cannedAcl)
+ .add("contentType", contentType)
+ .add("metadata", metadata)
+ .add("storageClass", storageClass)
+ .add("customTime", customTime)
+ .add("kmsKeyName", kmsKeyName)
+ .add("objectLockMode", objectLockMode)
+ .add("objectLockRetainUntilDate", objectLockRetainUntilDate)
+ .toString();
+ }
+
+ /**
+ * Returns a new {@link Builder} for creating a {@link CreateMultipartUploadRequest}.
+ *
+ * @return a new builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * A builder for {@link CreateMultipartUploadRequest}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static final class Builder {
+ private String bucket;
+ private String key;
+ private PredefinedAcl cannedAcl;
+ private String contentType;
+ private Map metadata;
+ private StorageClass storageClass;
+ private OffsetDateTime customTime;
+ private String kmsKeyName;
+ private ObjectLockMode objectLockMode;
+ private OffsetDateTime objectLockRetainUntilDate;
+
+ private Builder() {}
+
+ /**
+ * The bucket to which the object is being uploaded.
+ *
+ * @param bucket The bucket name
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder bucket(String bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
+ /**
+ * The name of the object.
+ *
+ * @see Object Naming
+ * @param key The object name
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * A canned ACL to apply to the object.
+ *
+ * @param cannedAcl The canned ACL
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder cannedAcl(PredefinedAcl cannedAcl) {
+ this.cannedAcl = cannedAcl;
+ return this;
+ }
+
+ /**
+ * The MIME type of the data you are uploading.
+ *
+ * @param contentType The Content-Type
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder contentType(String contentType) {
+ this.contentType = contentType;
+ return this;
+ }
+
+ /**
+ * The custom metadata of the object.
+ *
+ * @param metadata The custom metadata
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder metadata(Map metadata) {
+ this.metadata = metadata;
+ return this;
+ }
+
+ /**
+ * Gives each part of the upload and the resulting object a storage class besides the default
+ * storage class of the associated bucket.
+ *
+ * @param storageClass The Storage-Class
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder storageClass(StorageClass storageClass) {
+ this.storageClass = storageClass;
+ return this;
+ }
+
+ /**
+ * A user-specified date and time.
+ *
+ * @param customTime The custom time
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder customTime(OffsetDateTime customTime) {
+ this.customTime = customTime;
+ return this;
+ }
+
+ /**
+ * The customer-managed encryption key to use to encrypt the object. Refer: Customer
+ * Managed Keys
+ *
+ * @param kmsKeyName The Cloud KMS key
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder kmsKeyName(String kmsKeyName) {
+ this.kmsKeyName = kmsKeyName;
+ return this;
+ }
+
+ /**
+ * Mode of the object's retention configuration. GOVERNANCE corresponds to unlocked mode, and
+ * COMPLIANCE corresponds to locked mode.
+ *
+ * @param objectLockMode The object lock mode
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder objectLockMode(ObjectLockMode objectLockMode) {
+ this.objectLockMode = objectLockMode;
+ return this;
+ }
+
+ /**
+ * Date that determines the time until which the object is retained as immutable.
+ *
+ * @param objectLockRetainUntilDate The object lock retention until date
+ * @return this builder
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder objectLockRetainUntilDate(OffsetDateTime objectLockRetainUntilDate) {
+ this.objectLockRetainUntilDate = objectLockRetainUntilDate;
+ return this;
+ }
+
+ /**
+ * Creates a new {@link CreateMultipartUploadRequest} object.
+ *
+ * @return a new {@link CreateMultipartUploadRequest} object
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public CreateMultipartUploadRequest build() {
+ return new CreateMultipartUploadRequest(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadResponse.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadResponse.java
new file mode 100644
index 0000000000..87afb7fd62
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadResponse.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+import com.google.api.core.BetaApi;
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
+/**
+ * Represents the response from a CreateMultipartUpload request. This class encapsulates the details
+ * of the initiated multipart upload, including the bucket, key, and the unique upload ID.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@JacksonXmlRootElement(localName = "InitiateMultipartUploadResult")
+@BetaApi
+public final class CreateMultipartUploadResponse {
+
+ @JacksonXmlProperty(localName = "Bucket")
+ private String bucket;
+
+ @JacksonXmlProperty(localName = "Key")
+ private String key;
+
+ @JacksonXmlProperty(localName = "UploadId")
+ private String uploadId;
+
+ private CreateMultipartUploadResponse() {}
+
+ private CreateMultipartUploadResponse(Builder builder) {
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.uploadId = builder.uploadId;
+ }
+
+ /**
+ * Returns the name of the bucket where the multipart upload was initiated.
+ *
+ * @return The bucket name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the key (object name) for which the multipart upload was initiated.
+ *
+ * @return The object key.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Returns the unique identifier for this multipart upload. This ID must be included in all
+ * subsequent requests related to this upload (e.g., uploading parts, completing the upload).
+ *
+ * @return The upload ID.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String uploadId() {
+ return uploadId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof CreateMultipartUploadResponse)) {
+ return false;
+ }
+ CreateMultipartUploadResponse that = (CreateMultipartUploadResponse) o;
+ return Objects.equals(bucket, that.bucket)
+ && Objects.equals(key, that.key)
+ && Objects.equals(uploadId, that.uploadId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(bucket, key, uploadId);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("bucket", bucket)
+ .add("key", key)
+ .add("uploadId", uploadId)
+ .toString();
+ }
+
+ /**
+ * Creates a new builder for {@link CreateMultipartUploadResponse}.
+ *
+ * @return A new builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * A builder for {@link CreateMultipartUploadResponse} objects.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static final class Builder {
+ private String bucket;
+ private String key;
+ private String uploadId;
+
+ private Builder() {}
+
+ /**
+ * Sets the bucket name for the multipart upload.
+ *
+ * @param bucket The bucket name.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder bucket(String bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
+ /**
+ * Sets the key (object name) for the multipart upload.
+ *
+ * @param key The object key.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the upload ID for the multipart upload.
+ *
+ * @param uploadId The upload ID.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder uploadId(String uploadId) {
+ this.uploadId = uploadId;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link CreateMultipartUploadResponse} object.
+ *
+ * @return A new {@link CreateMultipartUploadResponse} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public CreateMultipartUploadResponse build() {
+ return new CreateMultipartUploadResponse(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ListPartsRequest.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ListPartsRequest.java
new file mode 100644
index 0000000000..2062bdfeb2
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ListPartsRequest.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.google.api.core.BetaApi;
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
+/**
+ * Represents a request to list the parts of a multipart upload.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class ListPartsRequest {
+ private final String bucket;
+
+ private final String key;
+
+ private final String uploadId;
+
+ private final Integer maxParts;
+
+ private final Integer partNumberMarker;
+
+ private ListPartsRequest(Builder builder) {
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.uploadId = builder.uploadId;
+ this.maxParts = builder.maxParts;
+ this.partNumberMarker = builder.partNumberMarker;
+ }
+
+ /**
+ * Returns the bucket name.
+ *
+ * @return the bucket name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the object name.
+ *
+ * @return the object name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Returns the upload ID.
+ *
+ * @return the upload ID.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String uploadId() {
+ return uploadId;
+ }
+
+ /**
+ * Returns the maximum number of parts to return.
+ *
+ * @return the maximum number of parts to return.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Integer getMaxParts() {
+ return maxParts;
+ }
+
+ /**
+ * Returns the part number marker.
+ *
+ * @return the part number marker.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Integer getPartNumberMarker() {
+ return partNumberMarker;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ListPartsRequest)) {
+ return false;
+ }
+ ListPartsRequest that = (ListPartsRequest) o;
+ return Objects.equals(bucket, that.bucket)
+ && Objects.equals(key, that.key)
+ && Objects.equals(uploadId, that.uploadId)
+ && Objects.equals(maxParts, that.maxParts)
+ && Objects.equals(partNumberMarker, that.partNumberMarker);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(bucket, key, uploadId, maxParts, partNumberMarker);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("bucket", bucket)
+ .add("key", key)
+ .add("uploadId", uploadId)
+ .add("maxParts", maxParts)
+ .add("partNumberMarker", partNumberMarker)
+ .toString();
+ }
+
+ /**
+ * Returns a new builder for this class.
+ *
+ * @return a new builder for this class.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * A builder for {@link ListPartsRequest}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static class Builder {
+ private String bucket;
+ private String key;
+ private String uploadId;
+ private Integer maxParts;
+ private Integer partNumberMarker;
+
+ private Builder() {}
+
+ /**
+ * Sets the bucket name.
+ *
+ * @param bucket the bucket name.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder bucket(String bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
+ /**
+ * Sets the object name.
+ *
+ * @param key the object name.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the upload ID.
+ *
+ * @param uploadId the upload ID.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder uploadId(String uploadId) {
+ this.uploadId = uploadId;
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of parts to return.
+ *
+ * @param maxParts the maximum number of parts to return.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder maxParts(Integer maxParts) {
+ this.maxParts = maxParts;
+ return this;
+ }
+
+ /**
+ * Sets the part number marker.
+ *
+ * @param partNumberMarker the part number marker.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder partNumberMarker(Integer partNumberMarker) {
+ this.partNumberMarker = partNumberMarker;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link ListPartsRequest} object.
+ *
+ * @return a new {@link ListPartsRequest} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public ListPartsRequest build() {
+ return new ListPartsRequest(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ListPartsResponse.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ListPartsResponse.java
new file mode 100644
index 0000000000..0b8f82c5a0
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ListPartsResponse.java
@@ -0,0 +1,385 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.google.api.core.BetaApi;
+import com.google.cloud.storage.StorageClass;
+import com.google.common.base.MoreObjects;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a response to a list parts request.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class ListPartsResponse {
+
+ @JacksonXmlProperty(localName = "Bucket")
+ private String bucket;
+
+ @JacksonXmlProperty(localName = "Key")
+ private String key;
+
+ @JacksonXmlProperty(localName = "UploadId")
+ private String uploadId;
+
+ @JacksonXmlProperty(localName = "PartNumberMarker")
+ private int partNumberMarker;
+
+ @JacksonXmlProperty(localName = "NextPartNumberMarker")
+ private int nextPartNumberMarker;
+
+ @JacksonXmlProperty(localName = "MaxParts")
+ private int maxParts;
+
+ @JsonAlias("truncated") // S3 returns "truncated", GCS returns "IsTruncated"
+ @JacksonXmlProperty(localName = "IsTruncated")
+ private boolean isTruncated;
+
+ @JacksonXmlProperty(localName = "StorageClass")
+ private StorageClass storageClass;
+
+ @JacksonXmlElementWrapper(useWrapping = false)
+ @JacksonXmlProperty(localName = "Part")
+ private List parts;
+
+ private ListPartsResponse() {}
+
+ private ListPartsResponse(Builder builder) {
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.uploadId = builder.uploadId;
+ this.partNumberMarker = builder.partNumberMarker;
+ this.nextPartNumberMarker = builder.nextPartNumberMarker;
+ this.maxParts = builder.maxParts;
+ this.isTruncated = builder.isTruncated;
+ this.storageClass = builder.storageClass;
+ this.parts = builder.parts;
+ }
+
+ /**
+ * Creates a new {@code Builder} for {@code ListPartsResponse} objects.
+ *
+ * @return A new {@code Builder} instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Returns the bucket name.
+ *
+ * @return the bucket name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String getBucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the object name.
+ *
+ * @return the object name.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String getKey() {
+ return key;
+ }
+
+ /**
+ * Returns the upload ID.
+ *
+ * @return the upload ID.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String getUploadId() {
+ return uploadId;
+ }
+
+ /**
+ * Returns the part number marker.
+ *
+ * @return the part number marker.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public int getPartNumberMarker() {
+ return partNumberMarker;
+ }
+
+ /**
+ * Returns the next part number marker.
+ *
+ * @return the next part number marker.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public int getNextPartNumberMarker() {
+ return nextPartNumberMarker;
+ }
+
+ /**
+ * Returns the maximum number of parts to return.
+ *
+ * @return the maximum number of parts to return.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public int getMaxParts() {
+ return maxParts;
+ }
+
+ /**
+ * Returns true if the response is truncated.
+ *
+ * @return true if the response is truncated.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public boolean isTruncated() {
+ return isTruncated;
+ }
+
+ /**
+ * Returns the storage class of the object.
+ *
+ * @return the storage class of the object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public StorageClass getStorageClass() {
+ return storageClass;
+ }
+
+ /**
+ * Returns the list of parts.
+ *
+ * @return the list of parts.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public List getParts() {
+ return parts;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ListPartsResponse)) {
+ return false;
+ }
+ ListPartsResponse that = (ListPartsResponse) o;
+ return Objects.equals(bucket, that.bucket)
+ && Objects.equals(key, that.key)
+ && Objects.equals(uploadId, that.uploadId)
+ && Objects.equals(partNumberMarker, that.partNumberMarker)
+ && Objects.equals(nextPartNumberMarker, that.nextPartNumberMarker)
+ && Objects.equals(maxParts, that.maxParts)
+ && Objects.equals(isTruncated, that.isTruncated)
+ && Objects.equals(storageClass, that.storageClass)
+ && Objects.equals(parts, that.parts);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ bucket,
+ key,
+ uploadId,
+ partNumberMarker,
+ nextPartNumberMarker,
+ maxParts,
+ isTruncated,
+ storageClass,
+ parts);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("bucket", bucket)
+ .add("key", key)
+ .add("uploadId", uploadId)
+ .add("partNumberMarker", partNumberMarker)
+ .add("nextPartNumberMarker", nextPartNumberMarker)
+ .add("maxParts", maxParts)
+ .add("isTruncated", isTruncated)
+ .add("storageClass", storageClass)
+ .add("parts", parts)
+ .toString();
+ }
+
+ /**
+ * Builder for {@code ListPartsResponse}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static final class Builder {
+ private String bucket;
+ private String key;
+ private String uploadId;
+ private int partNumberMarker;
+ private int nextPartNumberMarker;
+ private int maxParts;
+ private boolean isTruncated;
+ private StorageClass storageClass;
+ private List parts;
+
+ private Builder() {}
+
+ /**
+ * Sets the bucket name.
+ *
+ * @param bucket The bucket name.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setBucket(String bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
+ /**
+ * Sets the object name.
+ *
+ * @param key The object name.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setKey(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the upload ID.
+ *
+ * @param uploadId The upload ID.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setUploadId(String uploadId) {
+ this.uploadId = uploadId;
+ return this;
+ }
+
+ /**
+ * Sets the part number marker.
+ *
+ * @param partNumberMarker The part number marker.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setPartNumberMarker(int partNumberMarker) {
+ this.partNumberMarker = partNumberMarker;
+ return this;
+ }
+
+ /**
+ * Sets the next part number marker.
+ *
+ * @param nextPartNumberMarker The next part number marker.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setNextPartNumberMarker(int nextPartNumberMarker) {
+ this.nextPartNumberMarker = nextPartNumberMarker;
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of parts to return.
+ *
+ * @param maxParts The maximum number of parts to return.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setMaxParts(int maxParts) {
+ this.maxParts = maxParts;
+ return this;
+ }
+
+ /**
+ * Sets whether the response is truncated.
+ *
+ * @param isTruncated True if the response is truncated, false otherwise.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setIsTruncated(boolean isTruncated) {
+ this.isTruncated = isTruncated;
+ return this;
+ }
+
+ /**
+ * Sets the storage class of the object.
+ *
+ * @param storageClass The storage class of the object.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setStorageClass(StorageClass storageClass) {
+ this.storageClass = storageClass;
+ return this;
+ }
+
+ /**
+ * Sets the list of parts.
+ *
+ * @param parts The list of parts.
+ * @return The builder instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder setParts(List parts) {
+ this.parts = parts;
+ return this;
+ }
+
+ /**
+ * Builds a {@code ListPartsResponse} object.
+ *
+ * @return A new {@code ListPartsResponse} instance.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public ListPartsResponse build() {
+ return new ListPartsResponse(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ObjectLockMode.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ObjectLockMode.java
new file mode 100644
index 0000000000..da70a9130b
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ObjectLockMode.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.google.api.core.ApiFunction;
+import com.google.api.core.BetaApi;
+import com.google.cloud.StringEnumType;
+import com.google.cloud.StringEnumValue;
+
+/**
+ * Represents the object lock mode. See https://cloud.google.com/storage/docs/object-lock
+ * for details.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class ObjectLockMode extends StringEnumValue {
+ private static final long serialVersionUID = -1882734434792102329L;
+
+ private ObjectLockMode(String constant) {
+ super(constant);
+ }
+
+ private static final ApiFunction CONSTRUCTOR = ObjectLockMode::new;
+
+ private static final StringEnumType type =
+ new StringEnumType<>(ObjectLockMode.class, CONSTRUCTOR);
+
+ /**
+ * Governance mode. See https://cloud.google.com/storage/docs/object-lock
+ * for details.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi public static final ObjectLockMode GOVERNANCE = type.createAndRegister("GOVERNANCE");
+
+ /**
+ * Compliance mode. See https://cloud.google.com/storage/docs/object-lock
+ * for details.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi public static final ObjectLockMode COMPLIANCE = type.createAndRegister("COMPLIANCE");
+
+ /**
+ * Get the ObjectLockMode for the given String constant, and throw an exception if the constant is
+ * not recognized.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static ObjectLockMode valueOfStrict(String constant) {
+ return type.valueOfStrict(constant);
+ }
+
+ /**
+ * Get the ObjectLockMode for the given String constant, and allow unrecognized values.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static ObjectLockMode valueOf(String constant) {
+ return type.valueOf(constant);
+ }
+
+ /**
+ * Return the known values for ObjectLockMode.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static ObjectLockMode[] values() {
+ return type.values();
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/Part.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/Part.java
new file mode 100644
index 0000000000..6309316c7d
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/Part.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.google.api.core.BetaApi;
+import com.google.common.base.MoreObjects;
+import java.time.OffsetDateTime;
+import java.util.Objects;
+
+/**
+ * Represents a part of a multipart upload.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class Part {
+
+ @JacksonXmlProperty(localName = "PartNumber")
+ private int partNumber;
+
+ @JacksonXmlProperty(localName = "ETag")
+ private String eTag;
+
+ @JacksonXmlProperty(localName = "Size")
+ private long size;
+
+ @JacksonXmlProperty(localName = "LastModified")
+ private OffsetDateTime lastModified;
+
+ // for jackson
+ private Part() {}
+
+ private Part(Builder builder) {
+ this.partNumber = builder.partNumber;
+ this.eTag = builder.eTag;
+ this.size = builder.size;
+ this.lastModified = builder.lastModified;
+ }
+
+ /**
+ * Returns the part number.
+ *
+ * @return the part number.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public int partNumber() {
+ return partNumber;
+ }
+
+ /**
+ * Returns the ETag of the part.
+ *
+ * @return the ETag of the part.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String eTag() {
+ return eTag;
+ }
+
+ /**
+ * Returns the size of the part.
+ *
+ * @return the size of the part.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public long size() {
+ return size;
+ }
+
+ /**
+ * Returns the last modified time of the part.
+ *
+ * @return the last modified time of the part.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public OffsetDateTime lastModified() {
+ return lastModified;
+ }
+
+ /**
+ * Returns a new builder for this class.
+ *
+ * @return a new builder for this class.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Part)) {
+ return false;
+ }
+ Part that = (Part) o;
+ return Objects.equals(partNumber, that.partNumber)
+ && Objects.equals(eTag, that.eTag)
+ && Objects.equals(size, that.size)
+ && Objects.equals(lastModified, that.lastModified);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(partNumber, eTag, size, lastModified);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("partNumber", partNumber)
+ .add("eTag", eTag)
+ .add("size", size)
+ .add("lastModified", lastModified)
+ .toString();
+ }
+
+ /**
+ * A builder for {@link Part}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static final class Builder {
+ private int partNumber;
+ private String eTag;
+ private long size;
+ private OffsetDateTime lastModified;
+
+ private Builder() {}
+
+ /**
+ * Sets the part number.
+ *
+ * @param partNumber the part number.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder partNumber(int partNumber) {
+ this.partNumber = partNumber;
+ return this;
+ }
+
+ /**
+ * Sets the ETag of the part.
+ *
+ * @param eTag the ETag of the part.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder eTag(String eTag) {
+ this.eTag = eTag;
+ return this;
+ }
+
+ /**
+ * Sets the size of the part.
+ *
+ * @param size the size of the part.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder size(long size) {
+ this.size = size;
+ return this;
+ }
+
+ /**
+ * Sets the last modified time of the part.
+ *
+ * @param lastModified the last modified time of the part.
+ * @return this builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder lastModified(OffsetDateTime lastModified) {
+ this.lastModified = lastModified;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link Part} object.
+ *
+ * @return a new {@link Part} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Part build() {
+ return new Part(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/UploadPartRequest.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/UploadPartRequest.java
new file mode 100644
index 0000000000..9d07cf7fb0
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/UploadPartRequest.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.google.api.core.BetaApi;
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
+/**
+ * An object to represent an upload part request. An upload part request is used to upload a single
+ * part of a multipart upload.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class UploadPartRequest {
+
+ private final String bucket;
+ private final String key;
+ private final int partNumber;
+ private final String uploadId;
+
+ private UploadPartRequest(Builder builder) {
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.partNumber = builder.partNumber;
+ this.uploadId = builder.uploadId;
+ }
+
+ /**
+ * Returns the bucket to upload the part to.
+ *
+ * @return The bucket to upload the part to.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the key of the object to upload the part to.
+ *
+ * @return The key of the object to upload the part to.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Returns the part number of the part to upload.
+ *
+ * @return The part number of the part to upload.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public int partNumber() {
+ return partNumber;
+ }
+
+ /**
+ * Returns the upload ID of the multipart upload.
+ *
+ * @return The upload ID of the multipart upload.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String uploadId() {
+ return uploadId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof UploadPartRequest)) {
+ return false;
+ }
+ UploadPartRequest that = (UploadPartRequest) o;
+ return partNumber == that.partNumber
+ && Objects.equals(bucket, that.bucket)
+ && Objects.equals(key, that.key)
+ && Objects.equals(uploadId, that.uploadId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(bucket, key, partNumber, uploadId);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("bucket", bucket)
+ .add("key", key)
+ .add("partNumber", partNumber)
+ .add("uploadId", uploadId)
+ .toString();
+ }
+
+ /**
+ * Returns a new builder for an {@link UploadPartRequest}.
+ *
+ * @return A new builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * A builder for {@link UploadPartRequest}.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static class Builder {
+ private String bucket;
+ private String key;
+ private int partNumber;
+ private String uploadId;
+
+ private Builder() {}
+
+ /**
+ * Sets the bucket to upload the part to.
+ *
+ * @param bucket The bucket to upload the part to.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder bucket(String bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
+ /**
+ * Sets the key of the object to upload the part to.
+ *
+ * @param key The key of the object to upload the part to.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /**
+ * Sets the part number of the part to upload.
+ *
+ * @param partNumber The part number of the part to upload.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder partNumber(int partNumber) {
+ this.partNumber = partNumber;
+ return this;
+ }
+
+ /**
+ * Sets the upload ID of the multipart upload.
+ *
+ * @param uploadId The upload ID of the multipart upload.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder uploadId(String uploadId) {
+ this.uploadId = uploadId;
+ return this;
+ }
+
+ /**
+ * Builds the {@link UploadPartRequest}.
+ *
+ * @return The built {@link UploadPartRequest}.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public UploadPartRequest build() {
+ return new UploadPartRequest(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/UploadPartResponse.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/UploadPartResponse.java
new file mode 100644
index 0000000000..30dc72b0f7
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/UploadPartResponse.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2025 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.multipartupload.model;
+
+import com.google.api.core.BetaApi;
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
+/**
+ * Represents the response from uploading a part in a multipart upload. It contains the ETag and
+ * checksums of the uploaded part.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+@BetaApi
+public final class UploadPartResponse {
+
+ private final String eTag;
+ private final String md5;
+
+ private UploadPartResponse(Builder builder) {
+ this.eTag = builder.etag;
+ this.md5 = builder.md5;
+ }
+
+ /**
+ * Returns the ETag of the uploaded part.
+ *
+ * @return The ETag.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String eTag() {
+ return eTag;
+ }
+
+ /**
+ * Returns the MD5 hash of the uploaded part.
+ *
+ * @return The MD5 hash.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public String md5() {
+ return md5;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof UploadPartResponse)) {
+ return false;
+ }
+ UploadPartResponse that = (UploadPartResponse) o;
+ return Objects.equals(eTag, that.eTag) && Objects.equals(md5, that.md5);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(eTag, md5);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this).add("etag", eTag).add("md5", md5).toString();
+ }
+
+ /**
+ * Creates a new builder for creating an {@code UploadPartResponse}.
+ *
+ * @return A new builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * A builder for creating {@code UploadPartResponse} instances.
+ *
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public static class Builder {
+ private String etag;
+ private String md5;
+
+ private Builder() {}
+
+ /**
+ * Sets the ETag for the uploaded part.
+ *
+ * @param etag The ETag.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder eTag(String etag) {
+ this.etag = etag;
+ return this;
+ }
+
+ /**
+ * Sets the MD5 hash for the uploaded part.
+ *
+ * @param md5 The MD5 hash.
+ * @return This builder.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public Builder md5(String md5) {
+ this.md5 = md5;
+ return this;
+ }
+
+ /**
+ * Builds the {@code UploadPartResponse} object.
+ *
+ * @return The built {@code UploadPartResponse} object.
+ * @since 2.60.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public UploadPartResponse build() {
+ return new UploadPartResponse(this);
+ }
+ }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ChecksumResponseParserTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ChecksumResponseParserTest.java
new file mode 100644
index 0000000000..4dacacff90
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ChecksumResponseParserTest.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2025 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 com.google.common.truth.Truth.assertThat;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.cloud.storage.multipartupload.model.UploadPartResponse;
+import java.io.IOException;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ChecksumResponseParserTest {
+
+ @Test
+ public void testParse() throws IOException {
+ HttpResponse response =
+ createHttpResponse("\"test-etag\"", "crc32c=AAAAAA==,md5=rL0Y20zC+Fzt72VPzMSk2A==");
+
+ UploadPartResponse uploadPartResponse = ChecksumResponseParser.parseUploadResponse(response);
+
+ assertThat(uploadPartResponse.eTag()).isEqualTo("\"test-etag\"");
+ assertThat(uploadPartResponse.md5()).isEqualTo("rL0Y20zC+Fzt72VPzMSk2A==");
+ }
+
+ @Test
+ public void testExtractHashesFromHeader() throws IOException {
+ HttpResponse response =
+ createHttpResponse(null, "crc32c=AAAAAA==,md5=rL0Y20zC+Fzt72VPzMSk2A==");
+ Map hashes = ChecksumResponseParser.extractHashesFromHeader(response);
+ assertThat(hashes).containsEntry("crc32c", "AAAAAA==");
+ assertThat(hashes).containsEntry("md5", "rL0Y20zC+Fzt72VPzMSk2A==");
+ }
+
+ @Test
+ public void testExtractHashesFromHeader_singleHash() throws IOException {
+ HttpResponse response = createHttpResponse(null, "crc32c=AAAAAA==");
+ Map hashes = ChecksumResponseParser.extractHashesFromHeader(response);
+ assertThat(hashes).containsEntry("crc32c", "AAAAAA==");
+ assertThat(hashes).doesNotContainKey("md5");
+ }
+
+ @Test
+ public void testExtractHashesFromHeader_unknownHash() throws IOException {
+ HttpResponse response =
+ createHttpResponse(null, "crc32c=AAAAAA==,sha256=rL0Y20zC+Fzt72VPzMSk2A==");
+ Map hashes = ChecksumResponseParser.extractHashesFromHeader(response);
+ assertThat(hashes).containsEntry("crc32c", "AAAAAA==");
+ assertThat(hashes).doesNotContainKey("sha256");
+ }
+
+ @Test
+ public void testExtractHashesFromHeader_nullHeader() throws IOException {
+ HttpResponse response = createHttpResponse(null, null);
+ Map hashes = ChecksumResponseParser.extractHashesFromHeader(response);
+ assertThat(hashes).isEmpty();
+ }
+
+ @Test
+ public void testExtractHashesFromHeader_emptyHeader() throws IOException {
+ HttpResponse response = createHttpResponse(null, "");
+ Map hashes = ChecksumResponseParser.extractHashesFromHeader(response);
+ assertThat(hashes).isEmpty();
+ }
+
+ private HttpResponse createHttpResponse(String etag, String googHash) throws IOException {
+ MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse();
+ if (etag != null) {
+ lowLevelResponse.addHeader("ETag", etag);
+ }
+ if (googHash != null) {
+ lowLevelResponse.addHeader("x-goog-hash", googHash);
+ }
+ HttpTransport transport =
+ new MockHttpTransport.Builder().setLowLevelHttpResponse(lowLevelResponse).build();
+ HttpRequest request =
+ transport.createRequestFactory().buildGetRequest(new GenericUrl("http://example.com"));
+ return request.execute();
+ }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITMultipartUploadHttpRequestManagerTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITMultipartUploadHttpRequestManagerTest.java
new file mode 100644
index 0000000000..0b7acdbd47
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITMultipartUploadHttpRequestManagerTest.java
@@ -0,0 +1,843 @@
+/*
+ * Copyright 2025 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 com.google.common.truth.Truth.assertThat;
+import static io.grpc.netty.shaded.io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
+import static io.grpc.netty.shaded.io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static org.junit.Assert.assertThrows;
+
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.google.api.client.http.HttpResponseException;
+import com.google.cloud.NoCredentials;
+import com.google.cloud.storage.FakeHttpServer.HttpRequestHandler;
+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.ParallelFriendly;
+import com.google.cloud.storage.it.runner.annotations.SingleBackend;
+import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.CompletedMultipartUpload;
+import com.google.cloud.storage.multipartupload.model.CompletedPart;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.ListPartsRequest;
+import com.google.cloud.storage.multipartupload.model.ListPartsResponse;
+import com.google.cloud.storage.multipartupload.model.ObjectLockMode;
+import com.google.cloud.storage.multipartupload.model.Part;
+import com.google.cloud.storage.multipartupload.model.UploadPartRequest;
+import com.google.cloud.storage.multipartupload.model.UploadPartResponse;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.hash.Hashing;
+import io.grpc.netty.shaded.io.netty.buffer.ByteBuf;
+import io.grpc.netty.shaded.io.netty.buffer.Unpooled;
+import io.grpc.netty.shaded.io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.grpc.netty.shaded.io.netty.handler.codec.http.FullHttpRequest;
+import io.grpc.netty.shaded.io.netty.handler.codec.http.FullHttpResponse;
+import io.grpc.netty.shaded.io.netty.handler.codec.http.HttpResponseStatus;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Collections;
+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)
+@SingleBackend(Backend.PROD)
+@ParallelFriendly
+public final class ITMultipartUploadHttpRequestManagerTest {
+ private static final XmlMapper xmlMapper;
+
+ static {
+ xmlMapper = new XmlMapper();
+ xmlMapper.registerModule(new JavaTimeModule());
+ }
+
+ private MultipartUploadHttpRequestManager multipartUploadHttpRequestManager;
+ @Rule public final TemporaryFolder temp = new TemporaryFolder();
+
+ @Before
+ public void setUp() throws Exception {
+ HttpStorageOptions httpStorageOptions =
+ HttpStorageOptions.newBuilder()
+ .setProjectId("test-project")
+ .setCredentials(NoCredentials.getInstance())
+ .build();
+ multipartUploadHttpRequestManager =
+ MultipartUploadHttpRequestManager.createFrom(httpStorageOptions);
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_success() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ CreateMultipartUploadResponse response =
+ CreateMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .build();
+
+ CreateMultipartUploadResponse response =
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.bucket()).isEqualTo("test-bucket");
+ assertThat(response.key()).isEqualTo("test-key");
+ assertThat(response.uploadId()).isEqualTo("test-upload-id");
+ }
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_error() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ FullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), HttpResponseStatus.BAD_REQUEST);
+ resp.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .build();
+
+ assertThrows(
+ HttpResponseException.class,
+ () ->
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(
+ endpoint, request));
+ }
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_withCannedAcl() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ assertThat(req.headers().get("x-goog-acl")).isEqualTo("authenticated-read");
+ CreateMultipartUploadResponse response =
+ CreateMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .cannedAcl(Storage.PredefinedAcl.AUTHENTICATED_READ)
+ .build();
+
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+ }
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_withMetadata() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ assertThat(req.headers().get("x-goog-meta-key1")).isEqualTo("value1");
+ assertThat(req.headers().get("x-goog-meta-key2")).isEqualTo("value2");
+ CreateMultipartUploadResponse response =
+ CreateMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .metadata(ImmutableMap.of("key1", "value1", "key2", "value2"))
+ .build();
+
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+ }
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_withStorageClass() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ assertThat(req.headers().get("x-goog-storage-class")).isEqualTo("ARCHIVE");
+ CreateMultipartUploadResponse response =
+ CreateMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .storageClass(StorageClass.ARCHIVE)
+ .build();
+
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+ }
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_withKmsKeyName() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ assertThat(req.headers().get("x-goog-encryption-kms-key-name"))
+ .isEqualTo("projects/p/locations/l/keyRings/r/cryptoKeys/k");
+ CreateMultipartUploadResponse response =
+ CreateMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .kmsKeyName("projects/p/locations/l/keyRings/r/cryptoKeys/k")
+ .build();
+
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+ }
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_withObjectLockMode() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ assertThat(req.headers().get("x-goog-object-lock-mode")).isEqualTo("GOVERNANCE");
+ CreateMultipartUploadResponse response =
+ CreateMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .objectLockMode(ObjectLockMode.GOVERNANCE)
+ .build();
+
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+ }
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_withObjectLockRetainUntilDate() throws Exception {
+ OffsetDateTime retainUtil = OffsetDateTime.of(2024, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);
+ HttpRequestHandler handler =
+ req -> {
+ OffsetDateTime actual =
+ Utils.offsetDateTimeRfc3339Codec.decode(
+ req.headers().get("x-goog-object-lock-retain-until-date"));
+ assertThat(actual).isEqualTo(retainUtil);
+ CreateMultipartUploadResponse response =
+ CreateMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .objectLockRetainUntilDate(retainUtil)
+ .build();
+
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+ }
+ }
+
+ @Test
+ public void sendCreateMultipartUploadRequest_withCustomTime() throws Exception {
+ OffsetDateTime customTime = OffsetDateTime.of(2024, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);
+ HttpRequestHandler handler =
+ req -> {
+ OffsetDateTime actual =
+ Utils.offsetDateTimeRfc3339Codec.decode(req.headers().get("x-goog-custom-time"));
+ assertThat(actual).isEqualTo(customTime);
+ CreateMultipartUploadResponse response =
+ CreateMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .customTime(customTime)
+ .build();
+
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+ }
+ }
+
+ @Test
+ public void sendListPartsRequest_success() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ OffsetDateTime lastModified = OffsetDateTime.of(2024, 5, 8, 17, 50, 0, 0, ZoneOffset.UTC);
+ ListPartsResponse listPartsResponse =
+ ListPartsResponse.builder()
+ .setBucket("test-bucket")
+ .setKey("test-key")
+ .setUploadId("test-upload-id")
+ .setPartNumberMarker(0)
+ .setNextPartNumberMarker(1)
+ .setMaxParts(1)
+ .setIsTruncated(false)
+ .setParts(
+ Collections.singletonList(
+ Part.builder()
+ .partNumber(1)
+ .eTag("\"etag\"")
+ .size(123)
+ .lastModified(lastModified)
+ .build()))
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(listPartsResponse));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ ListPartsRequest request =
+ ListPartsRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .maxParts(1)
+ .partNumberMarker(0)
+ .build();
+
+ ListPartsResponse response =
+ multipartUploadHttpRequestManager.sendListPartsRequest(endpoint, request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getBucket()).isEqualTo("test-bucket");
+ assertThat(response.getKey()).isEqualTo("test-key");
+ assertThat(response.getUploadId()).isEqualTo("test-upload-id");
+ assertThat(response.getPartNumberMarker()).isEqualTo(0);
+ assertThat(response.getNextPartNumberMarker()).isEqualTo(1);
+ assertThat(response.getMaxParts()).isEqualTo(1);
+ assertThat(response.isTruncated()).isFalse();
+ assertThat(response.getParts()).hasSize(1);
+ Part part = response.getParts().get(0);
+ assertThat(part.partNumber()).isEqualTo(1);
+ assertThat(part.eTag()).isEqualTo("\"etag\"");
+ assertThat(part.size()).isEqualTo(123);
+ assertThat(part.lastModified())
+ .isEqualTo(OffsetDateTime.of(2024, 5, 8, 17, 50, 0, 0, ZoneOffset.UTC));
+ }
+ }
+
+ @Test
+ public void sendListPartsRequest_bucketNotFound() throws Exception {
+ HttpRequestHandler handler =
+ req ->
+ new DefaultFullHttpResponse(
+ req.protocolVersion(),
+ HttpResponseStatus.NOT_FOUND,
+ Unpooled.wrappedBuffer("Bucket not found".getBytes(StandardCharsets.UTF_8)));
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ ListPartsRequest request =
+ ListPartsRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+
+ assertThrows(
+ HttpResponseException.class,
+ () -> multipartUploadHttpRequestManager.sendListPartsRequest(endpoint, request));
+ }
+ }
+
+ @Test
+ public void sendListPartsRequest_keyNotFound() throws Exception {
+ HttpRequestHandler handler =
+ req ->
+ new DefaultFullHttpResponse(
+ req.protocolVersion(),
+ HttpResponseStatus.NOT_FOUND,
+ Unpooled.wrappedBuffer("Key not found".getBytes(StandardCharsets.UTF_8)));
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ ListPartsRequest request =
+ ListPartsRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+
+ assertThrows(
+ HttpResponseException.class,
+ () -> multipartUploadHttpRequestManager.sendListPartsRequest(endpoint, request));
+ }
+ }
+
+ @Test
+ public void sendListPartsRequest_badRequest() throws Exception {
+ HttpRequestHandler handler =
+ req ->
+ new DefaultFullHttpResponse(
+ req.protocolVersion(),
+ HttpResponseStatus.BAD_REQUEST,
+ Unpooled.wrappedBuffer("Invalid uploadId".getBytes(StandardCharsets.UTF_8)));
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ ListPartsRequest request =
+ ListPartsRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("invalid-upload-id")
+ .build();
+
+ assertThrows(
+ HttpResponseException.class,
+ () -> multipartUploadHttpRequestManager.sendListPartsRequest(endpoint, request));
+ }
+ }
+
+ @Test
+ public void sendListPartsRequest_errorResponse() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ FullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), HttpResponseStatus.BAD_REQUEST);
+ resp.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ ListPartsRequest request =
+ ListPartsRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+
+ assertThrows(
+ HttpResponseException.class,
+ () -> multipartUploadHttpRequestManager.sendListPartsRequest(endpoint, request));
+ }
+ }
+
+ @Test
+ public void sendAbortMultipartUploadRequest_success() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ assertThat(req.uri()).contains("?uploadId=test-upload-id");
+ AbortMultipartUploadResponse response = new AbortMultipartUploadResponse();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ AbortMultipartUploadRequest request =
+ AbortMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+
+ AbortMultipartUploadResponse response =
+ multipartUploadHttpRequestManager.sendAbortMultipartUploadRequest(endpoint, request);
+
+ assertThat(response).isNotNull();
+ }
+ }
+
+ @Test
+ public void sendAbortMultipartUploadRequest_error() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ FullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), HttpResponseStatus.BAD_REQUEST);
+ resp.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ AbortMultipartUploadRequest request =
+ AbortMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .build();
+
+ assertThrows(
+ HttpResponseException.class,
+ () ->
+ multipartUploadHttpRequestManager.sendAbortMultipartUploadRequest(endpoint, request));
+ }
+ }
+
+ @Test
+ public void sendCompleteMultipartUploadRequest_success() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ CompleteMultipartUploadResponse response =
+ CompleteMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .etag("\"test-etag\"")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CompleteMultipartUploadRequest request =
+ CompleteMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .multipartUpload(
+ CompletedMultipartUpload.builder()
+ .parts(
+ ImmutableList.of(
+ CompletedPart.builder().partNumber(1).eTag("\"etag1\"").build(),
+ CompletedPart.builder().partNumber(2).eTag("\"etag2\"").build()))
+ .build())
+ .build();
+
+ CompleteMultipartUploadResponse response =
+ multipartUploadHttpRequestManager.sendCompleteMultipartUploadRequest(endpoint, request);
+
+ assertThat(response).isNotNull();
+ assertThat(response.bucket()).isEqualTo("test-bucket");
+ assertThat(response.key()).isEqualTo("test-key");
+ assertThat(response.etag()).isEqualTo("\"test-etag\"");
+ }
+ }
+
+ @Test
+ public void sendCompleteMultipartUploadRequest_error() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ FullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), HttpResponseStatus.BAD_REQUEST);
+ resp.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CompleteMultipartUploadRequest request =
+ CompleteMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .multipartUpload(
+ CompletedMultipartUpload.builder()
+ .parts(
+ ImmutableList.of(
+ CompletedPart.builder().partNumber(1).eTag("\"etag1\"").build(),
+ CompletedPart.builder().partNumber(2).eTag("\"etag2\"").build()))
+ .build())
+ .build();
+
+ assertThrows(
+ HttpResponseException.class,
+ () ->
+ multipartUploadHttpRequestManager.sendCompleteMultipartUploadRequest(
+ endpoint, request));
+ }
+ }
+
+ @Test
+ public void sendCompleteMultipartUploadRequest_body() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ FullHttpRequest fullHttpRequest = (FullHttpRequest) req;
+ ByteBuf content = fullHttpRequest.content();
+ String body = content.toString(StandardCharsets.UTF_8);
+ assertThat(body)
+ .isEqualTo(
+ "1\"etag1\"2\"etag2\"");
+ CompleteMultipartUploadResponse response =
+ CompleteMultipartUploadResponse.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .etag("\"test-etag\"")
+ .build();
+ ByteBuf buf = Unpooled.wrappedBuffer(xmlMapper.writeValueAsBytes(response));
+
+ DefaultFullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), OK, buf);
+ resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ CompleteMultipartUploadRequest request =
+ CompleteMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .multipartUpload(
+ CompletedMultipartUpload.builder()
+ .parts(
+ ImmutableList.of(
+ CompletedPart.builder().partNumber(1).eTag("\"etag1\"").build(),
+ CompletedPart.builder().partNumber(2).eTag("\"etag2\"").build()))
+ .build())
+ .build();
+
+ multipartUploadHttpRequestManager.sendCompleteMultipartUploadRequest(endpoint, request);
+ }
+ }
+
+ @Test
+ public void sendUploadPartRequest_success() throws Exception {
+ String etag = "\"af1ed31420542285653c803a34aa839a\"";
+ String content = "hello world";
+ byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
+
+ HttpRequestHandler handler =
+ req -> {
+ assertThat(req.uri()).contains("?partNumber=1&uploadId=test-upload-id");
+ FullHttpRequest fullReq = (FullHttpRequest) req;
+ ByteBuf requestContent = fullReq.content();
+ byte[] receivedBytes = new byte[requestContent.readableBytes()];
+ requestContent.readBytes(receivedBytes);
+ assertThat(receivedBytes).isEqualTo(contentBytes);
+
+ DefaultFullHttpResponse resp = new DefaultFullHttpResponse(req.protocolVersion(), OK);
+ resp.headers().set("ETag", etag);
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ UploadPartRequest request =
+ UploadPartRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .partNumber(1)
+ .build();
+
+ UploadPartResponse response =
+ multipartUploadHttpRequestManager.sendUploadPartRequest(
+ endpoint, request, RewindableContent.of(ByteBuffer.wrap(contentBytes)));
+
+ assertThat(response).isNotNull();
+ assertThat(response.eTag()).isEqualTo(etag);
+ }
+ }
+
+ @Test
+ public void sendUploadPartRequest_withChecksums() throws Exception {
+ String etag = "\"af1ed31420542285653c803a34aa839a\"";
+ String content = "hello world";
+ byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
+ String md5 = Hashing.md5().hashBytes(contentBytes).toString();
+ String crc32c = "yZRlqg==";
+
+ HttpRequestHandler handler =
+ req -> {
+ assertThat(req.uri()).contains("?partNumber=1&uploadId=test-upload-id");
+ assertThat(req.headers().get("x-goog-hash")).contains("crc32c=" + crc32c);
+ FullHttpRequest fullReq = (FullHttpRequest) req;
+ ByteBuf requestContent = fullReq.content();
+ byte[] receivedBytes = new byte[requestContent.readableBytes()];
+ requestContent.readBytes(receivedBytes);
+ assertThat(receivedBytes).isEqualTo(contentBytes);
+
+ DefaultFullHttpResponse resp = new DefaultFullHttpResponse(req.protocolVersion(), OK);
+ resp.headers().set("ETag", etag);
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ UploadPartRequest request =
+ UploadPartRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .partNumber(1)
+ .build();
+
+ UploadPartResponse response =
+ multipartUploadHttpRequestManager.sendUploadPartRequest(
+ endpoint, request, RewindableContent.of(ByteBuffer.wrap(contentBytes)));
+
+ assertThat(response).isNotNull();
+ assertThat(response.eTag()).isEqualTo(etag);
+ }
+ }
+
+ @Test
+ public void sendUploadPartRequest_error() throws Exception {
+ HttpRequestHandler handler =
+ req -> {
+ FullHttpResponse resp =
+ new DefaultFullHttpResponse(req.protocolVersion(), HttpResponseStatus.BAD_REQUEST);
+ resp.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
+ return resp;
+ };
+
+ try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) {
+ URI endpoint = URI.create(fakeHttpServer.getEndpoint() + "/");
+ UploadPartRequest request =
+ UploadPartRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .uploadId("test-upload-id")
+ .partNumber(1)
+ .build();
+
+ assertThrows(
+ HttpResponseException.class,
+ () ->
+ multipartUploadHttpRequestManager.sendUploadPartRequest(
+ endpoint, request, RewindableContent.empty()));
+ }
+ }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/XmlObjectParserTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/XmlObjectParserTest.java
new file mode 100644
index 0000000000..c2c146b5fd
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/XmlObjectParserTest.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2025 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 com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.google.cloud.storage.multipartupload.model.ListPartsResponse;
+import com.google.common.base.MoreObjects;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+import org.junit.Before;
+import org.junit.Test;
+
+public class XmlObjectParserTest {
+
+ private XmlObjectParser xmlObjectParser;
+
+ @Before
+ public void setUp() {
+ xmlObjectParser = new XmlObjectParser(new XmlMapper());
+ }
+
+ @Test
+ public void testParseStringValueEnum() throws IOException {
+ // language=xml
+ String xml =
+ "\n" + " STANDARD" + "";
+ InputStream in = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8));
+ TestXmlObject2 expected = new TestXmlObject2(StorageClass.STANDARD);
+ TestXmlObject2 actual =
+ xmlObjectParser.parseAndClose(in, StandardCharsets.UTF_8, TestXmlObject2.class);
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testParseDoesNotFailOnUnknownFields() throws IOException {
+ // language=xml
+ String xml =
+ "\n"
+ + " STANDARD"
+ + " blah"
+ + "";
+ InputStream in = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8));
+ TestXmlObject2 expected = new TestXmlObject2(StorageClass.STANDARD);
+ TestXmlObject2 actual =
+ xmlObjectParser.parseAndClose(in, StandardCharsets.UTF_8, TestXmlObject2.class);
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testNestedParseStringValueEnum_undefined() throws IOException {
+ // language=xml
+ String xml =
+ "\n"
+ + " false\n"
+ + " bucket\n"
+ + " key\n"
+ + " \n"
+ + " 0\n"
+ + " 0\n"
+ + " 0\n"
+ + " false\n"
+ + " \n"
+ + " 1\n"
+ + " etag\n"
+ + " 33\n"
+ + " \n"
+ + " \n"
+ + "";
+ ListPartsResponse listPartsResponse =
+ xmlObjectParser.parseAndClose(new StringReader(xml), ListPartsResponse.class);
+ assertThat(listPartsResponse.getStorageClass()).isNull();
+ }
+
+ @Test
+ public void testNestedParseStringValueEnum_null() throws IOException {
+ // language=xml
+ String xml =
+ "\n"
+ + " false\n"
+ + " bucket\n"
+ + " key\n"
+ + " \n"
+ + " 0\n"
+ + " 0\n"
+ + " 0\n"
+ + " false\n"
+ + " "
+ + " \n"
+ + " 1\n"
+ + " etag\n"
+ + " 33\n"
+ + " \n"
+ + " \n"
+ + "";
+ ListPartsResponse listPartsResponse =
+ xmlObjectParser.parseAndClose(new StringReader(xml), ListPartsResponse.class);
+ assertThat(listPartsResponse.getStorageClass()).isNull();
+ }
+
+ @Test
+ public void testNestedParseStringValueEnum_nonNull() throws IOException {
+ // language=xml
+ String xml =
+ "\n"
+ + " false\n"
+ + " bucket\n"
+ + " key\n"
+ + " \n"
+ + " 0\n"
+ + " 0\n"
+ + " 0\n"
+ + " false\n"
+ + " STANDARD"
+ + " \n"
+ + " 1\n"
+ + " etag\n"
+ + " 33\n"
+ + " \n"
+ + " \n"
+ + "";
+ ListPartsResponse listPartsResponse =
+ xmlObjectParser.parseAndClose(new StringReader(xml), ListPartsResponse.class);
+ assertThat(listPartsResponse.getStorageClass()).isEqualTo(StorageClass.STANDARD);
+ }
+
+ private static class TestXmlObject {}
+
+ private static final class TestXmlObject2 {
+ @JacksonXmlProperty(localName = "storageClass")
+ private StorageClass storageClass;
+
+ private TestXmlObject2() {}
+
+ public TestXmlObject2(StorageClass storageClass) {
+ this.storageClass = storageClass;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof TestXmlObject2)) {
+ return false;
+ }
+ TestXmlObject2 that = (TestXmlObject2) o;
+ return Objects.equals(storageClass, that.storageClass);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(storageClass);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this).add("storageClass", storageClass).toString();
+ }
+ }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITMultipartUploadClientTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITMultipartUploadClientTest.java
new file mode 100644
index 0000000000..526b5533e6
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITMultipartUploadClientTest.java
@@ -0,0 +1,418 @@
+/*
+ * Copyright 2024 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.cloud.storage.TestUtils.xxd;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.cloud.ReadChannel;
+import com.google.cloud.storage.Blob;
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.DataGenerator;
+import com.google.cloud.storage.HttpStorageOptions;
+import com.google.cloud.storage.MultipartUploadClient;
+import com.google.cloud.storage.MultipartUploadSettings;
+import com.google.cloud.storage.RequestBody;
+import com.google.cloud.storage.Storage;
+import com.google.cloud.storage.StorageException;
+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.multipartupload.model.AbortMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CompletedMultipartUpload;
+import com.google.cloud.storage.multipartupload.model.CompletedPart;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.ListPartsRequest;
+import com.google.cloud.storage.multipartupload.model.ListPartsResponse;
+import com.google.cloud.storage.multipartupload.model.UploadPartRequest;
+import com.google.cloud.storage.multipartupload.model.UploadPartResponse;
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(StorageITRunner.class)
+@CrossRun(
+ transports = {Transport.HTTP},
+ backends = {Backend.PROD})
+public final class ITMultipartUploadClientTest {
+
+ private static final int _5MiB = 5 * 1024 * 1024;
+
+ @Inject public BucketInfo bucket;
+
+ @Inject public Storage injectedStorage;
+
+ @Inject public Transport transport;
+
+ @Inject public Generator generator;
+
+ private MultipartUploadClient multipartUploadClient;
+
+ @Before
+ public void setUp() {
+ multipartUploadClient =
+ MultipartUploadClient.create(
+ MultipartUploadSettings.of((HttpStorageOptions) injectedStorage.getOptions()));
+ }
+
+ @Test
+ public void testMultipartUpload() throws IOException {
+ doTest(12 * _5MiB + 37);
+ }
+
+ @Test
+ public void testMultipartUpload_parallel() throws Exception {
+ // This test is slow and resource-intensive.
+ long objectSize = 513 * 1024 * 1024 + 29; // 513 MiB + 29 bytes
+ int partSize = 8 * 1024 * 1024; // 8 MiB
+
+ Path tempFile = Files.createTempFile("multipart-upload-it", ".bin");
+ try {
+ createRandomFile(tempFile, objectSize);
+
+ BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
+ CreateMultipartUploadResponse createResponse = createMultipartUpload(info);
+ String uploadId = createResponse.uploadId();
+
+ List completedParts =
+ parallelUpload(info, uploadId, tempFile, objectSize, partSize);
+
+ completeMultipartUpload(info, uploadId, completedParts);
+
+ Blob result = injectedStorage.get(info.getBlobId());
+ assertThat(result).isNotNull();
+ assertThat(result.getSize()).isEqualTo(objectSize);
+
+ verifyContents(info, tempFile);
+ } finally {
+ Files.deleteIfExists(tempFile);
+ }
+ }
+
+ @Test
+ public void testAbort() {
+ BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
+
+ CreateMultipartUploadResponse createResponse = createMultipartUpload(info);
+ String uploadId = createResponse.uploadId();
+
+ byte[] bytes = DataGenerator.rand(new Random()).genBytes(_5MiB);
+
+ uploadPart(info, uploadId, 1, bytes);
+ abortMultipartUpload(info, uploadId);
+
+ Blob blob = injectedStorage.get(info.getBlobId());
+ assertThat(blob).isNull();
+ }
+
+ @Test
+ public void testMultipartUpload_singlePart() throws IOException {
+ doTest(_5MiB - 1);
+ }
+
+ @Test
+ public void testMultipartUpload_zeroByteFile() {
+ BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
+
+ CreateMultipartUploadResponse createResponse = createMultipartUpload(info);
+ String uploadId = createResponse.uploadId();
+ byte[] bytes = new byte[0];
+
+ UploadPartResponse uploadPartResponse = uploadPart(info, uploadId, 1, bytes);
+
+ List completedParts = new ArrayList<>();
+ completedParts.add(
+ CompletedPart.builder().partNumber(1).eTag(uploadPartResponse.eTag()).build());
+
+ completeMultipartUpload(info, uploadId, completedParts);
+
+ Blob result = injectedStorage.get(info.getBlobId());
+ byte[] actual = injectedStorage.readAllBytes(info.getBlobId());
+
+ assertThat(result).isNotNull();
+ assertThat(result.getSize()).isEqualTo(0);
+ assertBytesEqual(actual, new byte[0]);
+ }
+
+ @Test
+ public void testComplete_noParts() {
+ BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
+ CreateMultipartUploadResponse createResponse = createMultipartUpload(info);
+ String uploadId = createResponse.uploadId();
+
+ List completedParts = new ArrayList<>();
+ try {
+ completeMultipartUpload(info, uploadId, completedParts);
+ fail("Expected StorageException");
+ } catch (StorageException e) {
+ assertThat(e.getMessage()).contains("MalformedCompleteMultipartUploadRequest");
+ } finally {
+ // cleanup
+ abortMultipartUpload(info, uploadId);
+ }
+ }
+
+ @Test
+ public void testListParts_afterAbort() {
+ BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
+
+ CreateMultipartUploadResponse createResponse = createMultipartUpload(info);
+ String uploadId = createResponse.uploadId();
+
+ byte[] bytes = DataGenerator.rand(new Random()).genBytes(_5MiB);
+ uploadPart(info, uploadId, 1, bytes);
+
+ abortMultipartUpload(info, uploadId);
+
+ try {
+ ListPartsRequest.Builder listPartsBuilder =
+ ListPartsRequest.builder()
+ .bucket(info.getBucket())
+ .key(info.getName())
+ .uploadId(uploadId);
+ multipartUploadClient.listParts(listPartsBuilder.build());
+ fail("Expected StorageException");
+ } catch (StorageException e) {
+ assertThat(e.getMessage()).contains("The requested upload was not found.");
+ }
+ }
+
+ @Test
+ public void testComplete_wrongETag() {
+ BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
+ CreateMultipartUploadResponse createResponse = createMultipartUpload(info);
+ String uploadId = createResponse.uploadId();
+
+ Random rand = new Random();
+ byte[] bytes1 = DataGenerator.rand(rand).genBytes(_5MiB);
+ UploadPartResponse uploadPartResponse1 = uploadPart(info, uploadId, 1, bytes1);
+
+ byte[] bytes2 = DataGenerator.rand(rand).genBytes(_5MiB);
+ uploadPart(info, uploadId, 2, bytes2);
+
+ List completedParts = new ArrayList<>();
+ completedParts.add(
+ CompletedPart.builder().partNumber(1).eTag(uploadPartResponse1.eTag()).build());
+ completedParts.add(
+ CompletedPart.builder().partNumber(2).eTag("\"dummytag\"").build()); // wrong etag
+
+ try {
+ completeMultipartUpload(info, uploadId, completedParts);
+ fail("Expected StorageException");
+ } catch (StorageException e) {
+ assertThat(e.getMessage()).contains("The requested upload part was not found.");
+ } finally {
+ abortMultipartUpload(info, uploadId);
+ }
+ }
+
+ private void doTest(int objectSizeBytes) throws IOException {
+ BlobInfo info = BlobInfo.newBuilder(bucket, generator.randomObjectName()).build();
+
+ CreateMultipartUploadResponse createResponse = createMultipartUpload(info);
+ String uploadId = createResponse.uploadId();
+ byte[] bytes = DataGenerator.rand(new Random()).genBytes(objectSizeBytes);
+
+ List completedParts = new ArrayList<>();
+ int partNumber = 1;
+ for (int i = 0; i < objectSizeBytes; i += _5MiB) {
+ int len = Math.min(_5MiB, objectSizeBytes - i);
+ byte[] partBuffer = java.util.Arrays.copyOfRange(bytes, i, i + len);
+ UploadPartResponse uploadPartResponse = uploadPart(info, uploadId, partNumber, partBuffer);
+ completedParts.add(
+ CompletedPart.builder().partNumber(partNumber).eTag(uploadPartResponse.eTag()).build());
+ partNumber++;
+ }
+ completedParts.sort(Comparator.comparingInt(CompletedPart::partNumber));
+
+ ListPartsRequest.Builder listPartsBuilder =
+ ListPartsRequest.builder().bucket(info.getBucket()).key(info.getName()).uploadId(uploadId);
+ ListPartsResponse listPartsResponse = multipartUploadClient.listParts(listPartsBuilder.build());
+ assertThat(listPartsResponse.getParts()).hasSize(completedParts.size());
+
+ completeMultipartUpload(info, uploadId, completedParts);
+
+ Blob result = injectedStorage.get(info.getBlobId());
+ byte[] actual = injectedStorage.readAllBytes(info.getBlobId());
+
+ assertThat(result).isNotNull();
+ assertBytesEqual(actual, bytes);
+ }
+
+ private void assertBytesEqual(byte[] actual, byte[] expected) {
+ assertThat(actual).isEqualTo(expected);
+ assertThat(xxd(actual)).isEqualTo(xxd(expected));
+ }
+
+ private CreateMultipartUploadResponse createMultipartUpload(BlobInfo info) {
+ CreateMultipartUploadRequest createRequest =
+ CreateMultipartUploadRequest.builder().bucket(info.getBucket()).key(info.getName()).build();
+ return multipartUploadClient.createMultipartUpload(createRequest);
+ }
+
+ private UploadPartResponse uploadPart(
+ BlobInfo info, String uploadId, int partNumber, byte[] bytes) {
+ RequestBody body = RequestBody.of(ByteBuffer.wrap(bytes));
+ return uploadPart(info, uploadId, partNumber, body);
+ }
+
+ private UploadPartResponse uploadPart(
+ BlobInfo info, String uploadId, int partNumber, RequestBody body) {
+ UploadPartRequest uploadPartRequest =
+ UploadPartRequest.builder()
+ .partNumber(partNumber)
+ .uploadId(uploadId)
+ .bucket(info.getBucket())
+ .key(info.getName())
+ .build();
+ return multipartUploadClient.uploadPart(uploadPartRequest, body);
+ }
+
+ private void completeMultipartUpload(BlobInfo info, String uploadId, List parts) {
+ CompletedMultipartUpload completedMultipartUpload =
+ CompletedMultipartUpload.builder().parts(parts).build();
+ CompleteMultipartUploadRequest completeRequest =
+ CompleteMultipartUploadRequest.builder()
+ .bucket(info.getBucket())
+ .key(info.getName())
+ .uploadId(uploadId)
+ .multipartUpload(completedMultipartUpload)
+ .build();
+ multipartUploadClient.completeMultipartUpload(completeRequest);
+ }
+
+ private void abortMultipartUpload(BlobInfo info, String uploadId) {
+ AbortMultipartUploadRequest abortRequest =
+ AbortMultipartUploadRequest.builder()
+ .bucket(info.getBucket())
+ .key(info.getName())
+ .uploadId(uploadId)
+ .build();
+ multipartUploadClient.abortMultipartUpload(abortRequest);
+ }
+
+ private void createRandomFile(Path path, long size) throws IOException {
+ try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(path))) {
+ byte[] buffer = new byte[1024 * 1024]; // 1MB buffer
+ Random random = new Random();
+ for (long i = 0; i < size; i += buffer.length) {
+ random.nextBytes(buffer);
+ int len = (int) Math.min(buffer.length, size - i);
+ os.write(buffer, 0, len);
+ }
+ }
+ }
+
+ private List parallelUpload(
+ BlobInfo info, String uploadId, Path localFile, long objectSize, int partSize)
+ throws ExecutionException, InterruptedException {
+ int numThreads = Runtime.getRuntime().availableProcessors();
+ ExecutorService executor = Executors.newFixedThreadPool(numThreads);
+ List> futures = new ArrayList<>();
+
+ long numParts = (objectSize + partSize - 1) / partSize;
+
+ for (int i = 0; i < numParts; i++) {
+ final int partNumber = i + 1;
+ final long offset = (long) i * partSize;
+ final long len = Math.min(partSize, objectSize - offset);
+
+ Callable uploadTask =
+ () -> {
+ ByteBuffer partBuffer = ByteBuffer.allocate((int) len);
+ try (FileChannel fileChannel = FileChannel.open(localFile, StandardOpenOption.READ)) {
+ fileChannel.read(partBuffer, offset);
+ }
+ partBuffer.flip();
+ RequestBody partBody = RequestBody.of(partBuffer);
+ UploadPartResponse uploadPartResponse =
+ uploadPart(info, uploadId, partNumber, partBody);
+ return CompletedPart.builder()
+ .partNumber(partNumber)
+ .eTag(uploadPartResponse.eTag())
+ .build();
+ };
+ futures.add(executor.submit(uploadTask));
+ }
+
+ List completedParts = new ArrayList<>();
+ for (Future future : futures) {
+ completedParts.add(future.get());
+ }
+ executor.shutdown();
+
+ completedParts.sort(Comparator.comparingInt(CompletedPart::partNumber));
+ return completedParts;
+ }
+
+ private void verifyContents(BlobInfo info, Path expectedFile) throws IOException {
+ try (ReadChannel reader = injectedStorage.reader(info.getBlobId());
+ InputStream expectedStream = new BufferedInputStream(Files.newInputStream(expectedFile))) {
+
+ ByteBuffer cloudBuffer = ByteBuffer.allocate(1024 * 1024); // 1MB buffer
+
+ while (reader.read(cloudBuffer) > 0) {
+ cloudBuffer.flip();
+
+ byte[] actualBytes = new byte[cloudBuffer.remaining()];
+ cloudBuffer.get(actualBytes);
+
+ byte[] expectedBytes = new byte[actualBytes.length];
+ int bytesRead = 0;
+ while (bytesRead < expectedBytes.length) {
+ int readResult =
+ expectedStream.read(expectedBytes, bytesRead, expectedBytes.length - bytesRead);
+ if (readResult == -1) {
+ break;
+ }
+ bytesRead += readResult;
+ }
+
+ assertThat(bytesRead).isEqualTo(expectedBytes.length);
+ assertBytesEqual(actualBytes, expectedBytes);
+ cloudBuffer.clear();
+ }
+ assertThat(expectedStream.read()).isEqualTo(-1); // Ensure we have read the whole local file
+ }
+ }
+}
From feb191214e1551af35a5c391196e296ab4ce5372 Mon Sep 17 00:00:00 2001
From: Mend Renovate
Date: Mon, 3 Nov 2025 23:55:45 +0000
Subject: [PATCH 11/14] build(deps): update googleapis/sdk-platform-java action
to v2.64.0 (#3380)
---
.github/workflows/hermetic_library_generation.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/hermetic_library_generation.yaml b/.github/workflows/hermetic_library_generation.yaml
index 9872c68489..76c6ec9ee6 100644
--- a/.github/workflows/hermetic_library_generation.yaml
+++ b/.github/workflows/hermetic_library_generation.yaml
@@ -43,7 +43,7 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.CLOUD_JAVA_BOT_TOKEN }}
- - uses: googleapis/sdk-platform-java/.github/scripts@v2.63.0
+ - uses: googleapis/sdk-platform-java/.github/scripts@v2.64.0
if: env.SHOULD_RUN == 'true'
with:
base_ref: ${{ github.base_ref }}
From ac3be4b7e82d9340ede7d527a26ffe3e2ba58909 Mon Sep 17 00:00:00 2001
From: BenWhitehead
Date: Tue, 4 Nov 2025 15:45:55 -0500
Subject: [PATCH 12/14] fix: call response.disconnect() after resolving
resumable upload url (#3385)
---
.../com/google/cloud/storage/spi/v1/HttpStorageRpc.java | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
index ca11f96673..20650a11d0 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java
@@ -1156,7 +1156,9 @@ public String open(StorageObject object, Map