com.google.guava
guava
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 000000000..768eed545
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClient.java
@@ -0,0 +1,52 @@
+/*
+ * 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.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+import java.io.IOException;
+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
+ */
+@BetaApi
+@InternalExtensionOnly
+public abstract class MultipartUploadClient {
+
+ MultipartUploadClient() {}
+
+ @BetaApi
+ public abstract CreateMultipartUploadResponse createMultipartUpload(
+ CreateMultipartUploadRequest request) throws IOException;
+
+ @BetaApi
+ public static MultipartUploadClient create(MultipartUploadSettings config) {
+ HttpStorageOptions options = config.getOptions();
+ return new MultipartUploadClientImpl(
+ URI.create(options.getHost()),
+ options.createRetrier(),
+ MultipartUploadHttpRequestManager.createFrom(options));
+ }
+}
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 000000000..853186e77
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadClientImpl.java
@@ -0,0 +1,50 @@
+/*
+ * 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.cloud.storage.Retrying.Retrier;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+import java.io.IOException;
+import java.net.URI;
+
+/**
+ * This class is an implementation of {@link MultipartUploadClient} that uses the Google Cloud
+ * Storage XML API to perform multipart uploads.
+ */
+@BetaApi
+final class MultipartUploadClientImpl extends MultipartUploadClient {
+
+ private final MultipartUploadHttpRequestManager httpRequestManager;
+ private final Retrier retrier;
+ private final URI uri;
+
+ MultipartUploadClientImpl(
+ URI uri,
+ Retrier retrier,
+ MultipartUploadHttpRequestManager multipartUploadHttpRequestManager) {
+ this.httpRequestManager = multipartUploadHttpRequestManager;
+ this.retrier = retrier;
+ this.uri = uri;
+ }
+
+ @BetaApi
+ public CreateMultipartUploadResponse createMultipartUpload(CreateMultipartUploadRequest request)
+ throws IOException {
+ return httpRequestManager.sendCreateMultipartUploadRequest(uri, request);
+ }
+}
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 000000000..eb8db6cc6
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadHttpRequestManager.java
@@ -0,0 +1,158 @@
+/*
+ * 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.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.multipartupload.model.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+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.charset.StandardCharsets;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+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 encodedBucket = urlEncode(request.bucket());
+ String encodedKey = urlEncode(request.key());
+ String resourcePath = "/" + encodedBucket + "/" + encodedKey;
+ String createUri = uri.toString() + resourcePath + "?uploads";
+
+ 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);
+ }
+
+ @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 addHeadersForCreateMultipartUpload(
+ CreateMultipartUploadRequest request, HttpHeaders headers) {
+ // TODO(shreyassinha): add a PredefinedAcl::getXmlEntry with the corresponding value from
+ // https://cloud.google.com/storage/docs/xml-api/reference-headers#xgoogacl
+ if (request.getCannedAcl() != null) {
+ headers.put("x-goog-acl", request.getCannedAcl().toString());
+ }
+ // TODO(shreyassinha) Add encoding for x-goog-meta-* headers
+ if (request.getMetadata() != null) {
+ for (Map.Entry entry : request.getMetadata().entrySet()) {
+ if (entry.getKey() != null || entry.getValue() != null) {
+ headers.put("x-goog-meta-" + entry.getKey(), entry.getValue());
+ }
+ }
+ }
+ 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) throws UnsupportedEncodingException {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+ }
+
+ 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);
+ }
+
+ 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 000000000..fbf55b3bf
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/MultipartUploadSettings.java
@@ -0,0 +1,59 @@
+/*
+ * 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;
+
+ /**
+ * Constructs a {@code MultipartUploadSettings} instance.
+ *
+ * @param options The {@link HttpStorageOptions} to use for multipart uploads.
+ */
+ 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/Utils.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java
index eece2fe79..90d6122c9 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Utils.java
@@ -84,6 +84,11 @@ final class Utils {
static final Codec 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 000000000..27c91fef4
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/XmlObjectParser.java
@@ -0,0 +1,63 @@
+/*
+ * 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.dataformat.xml.XmlMapper;
+import com.google.api.client.util.ObjectParser;
+import com.google.common.annotations.VisibleForTesting;
+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;
+
+final class XmlObjectParser implements ObjectParser {
+ private final XmlMapper xmlMapper;
+
+ @VisibleForTesting
+ public XmlObjectParser(XmlMapper xmlMapper) {
+ this.xmlMapper = xmlMapper;
+ }
+
+ @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));
+ }
+}
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 000000000..44161ad16
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadRequest.java
@@ -0,0 +1,348 @@
+/*
+ * 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.
+ */
+@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
+ */
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the name of the object.
+ *
+ * @see Object Naming
+ * @return The object name
+ */
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Returns a canned ACL to apply to the object.
+ *
+ * @return The canned ACL
+ */
+ public PredefinedAcl getCannedAcl() {
+ return cannedAcl;
+ }
+
+ /**
+ * Returns the MIME type of the data you are uploading.
+ *
+ * @return The Content-Type
+ */
+ public String getContentType() {
+ return contentType;
+ }
+
+ /**
+ * Returns the custom metadata of the object.
+ *
+ * @return The custom metadata
+ */
+ public Map getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * Returns the storage class for the object.
+ *
+ * @return The Storage-Class
+ */
+ public StorageClass getStorageClass() {
+ return storageClass;
+ }
+
+ /**
+ * Returns a user-specified date and time.
+ *
+ * @return The custom time
+ */
+ public OffsetDateTime getCustomTime() {
+ return customTime;
+ }
+
+ /**
+ * Returns the customer-managed encryption key to use to encrypt the object.
+ *
+ * @return The Cloud KMS key
+ */
+ public String getKmsKeyName() {
+ return kmsKeyName;
+ }
+
+ /**
+ * Returns the mode of the object's retention configuration.
+ *
+ * @return The object lock mode
+ */
+ 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
+ */
+ 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
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @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
+ */
+ 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
+ */
+ 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
+ */
+ 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
+ */
+ public Builder contentType(String contentType) {
+ this.contentType = contentType;
+ return this;
+ }
+
+ /**
+ * The custom metadata of the object.
+ *
+ * @param metadata The custom metadata
+ * @return this builder
+ */
+ 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
+ */
+ public Builder storageClass(StorageClass storageClass) {
+ this.storageClass = storageClass;
+ return this;
+ }
+
+ /**
+ * A user-specified date and time.
+ *
+ * @param customTime The custom time
+ * @return this builder
+ */
+ 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
+ */
+ 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
+ */
+ 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
+ */
+ public Builder objectLockRetainUntilDate(OffsetDateTime objectLockRetainUntilDate) {
+ this.objectLockRetainUntilDate = objectLockRetainUntilDate;
+ return this;
+ }
+
+ /**
+ * Creates a new {@link CreateMultipartUploadRequest} object.
+ *
+ * @return a new {@link CreateMultipartUploadRequest} object
+ */
+ 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 000000000..5487dd731
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/CreateMultipartUploadResponse.java
@@ -0,0 +1,166 @@
+/*
+ * 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.
+ */
+@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(Builder builder) {
+ this.bucket = builder.bucket;
+ this.key = builder.key;
+ this.uploadId = builder.uploadId;
+ }
+
+ private CreateMultipartUploadResponse() {}
+
+ /**
+ * Returns the name of the bucket where the multipart upload was initiated.
+ *
+ * @return The bucket name.
+ */
+ public String bucket() {
+ return bucket;
+ }
+
+ /**
+ * Returns the key (object name) for which the multipart upload was initiated.
+ *
+ * @return The object key.
+ */
+ 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.
+ */
+ 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.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /** A builder for {@link CreateMultipartUploadResponse} objects. */
+ @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.
+ */
+ 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.
+ */
+ 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.
+ */
+ public Builder uploadId(String uploadId) {
+ this.uploadId = uploadId;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link CreateMultipartUploadResponse} object.
+ *
+ * @return A new {@link CreateMultipartUploadResponse} object.
+ */
+ public CreateMultipartUploadResponse build() {
+ return new CreateMultipartUploadResponse(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 000000000..a058719e1
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/multipartupload/model/ObjectLockMode.java
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+@BetaApi
+public final class ObjectLockMode extends StringEnumValue {
+ private static final long serialVersionUID = -1882734434792102329L;
+
+ private ObjectLockMode(String constant) {
+ super(constant);
+ }
+
+ private static final ApiFunction CONSTRUCTOR =
+ new ApiFunction() {
+ @Override
+ public ObjectLockMode apply(String constant) {
+ return new ObjectLockMode(constant);
+ }
+ };
+
+ private static final StringEnumType type =
+ new StringEnumType(ObjectLockMode.class, CONSTRUCTOR);
+
+ /**
+ * Governance mode. See https://cloud.google.com/storage/docs/object-lock
+ * for details.
+ */
+ public static final ObjectLockMode GOVERNANCE = type.createAndRegister("GOVERNANCE");
+
+ /**
+ * Compliance mode. See https://cloud.google.com/storage/docs/object-lock
+ * for details.
+ */
+ 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.
+ */
+ public static ObjectLockMode valueOfStrict(String constant) {
+ return type.valueOfStrict(constant);
+ }
+
+ /** Get the ObjectLockMode for the given String constant, and allow unrecognized values. */
+ public static ObjectLockMode valueOf(String constant) {
+ return type.valueOf(constant);
+ }
+
+ /** Return the known values for ObjectLockMode. */
+ public static ObjectLockMode[] values() {
+ return type.values();
+ }
+}
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 000000000..72625d0ba
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITMultipartUploadHttpRequestManagerTest.java
@@ -0,0 +1,372 @@
+/*
+ * 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.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.CreateMultipartUploadRequest;
+import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadResponse;
+import com.google.cloud.storage.multipartupload.model.ObjectLockMode;
+import com.google.common.collect.ImmutableMap;
+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.FullHttpResponse;
+import io.grpc.netty.shaded.io.netty.handler.codec.http.HttpResponseStatus;
+import java.net.URI;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+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 = new XmlMapper();
+ 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = fakeHttpServer.getEndpoint();
+ CreateMultipartUploadRequest request =
+ CreateMultipartUploadRequest.builder()
+ .bucket("test-bucket")
+ .key("test-key")
+ .contentType("application/octet-stream")
+ .customTime(customTime)
+ .build();
+
+ multipartUploadHttpRequestManager.sendCreateMultipartUploadRequest(endpoint, request);
+ }
+ }
+}
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 000000000..c4acd8c64
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/XmlObjectParserTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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 org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class XmlObjectParserTest {
+
+ @Mock private XmlMapper xmlMapper;
+
+ private AutoCloseable mocks;
+ private XmlObjectParser xmlObjectParser;
+
+ @Before
+ public void setUp() {
+ mocks = MockitoAnnotations.openMocks(this);
+ xmlObjectParser = new XmlObjectParser(xmlMapper);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ mocks.close();
+ }
+
+ @Test
+ public void testParseAndClose() throws IOException {
+ InputStream in = new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8));
+ TestXmlObject expected = new TestXmlObject();
+ when(xmlMapper.readValue(any(Reader.class), any(Class.class))).thenReturn(expected);
+ TestXmlObject actual =
+ xmlObjectParser.parseAndClose(in, StandardCharsets.UTF_8, TestXmlObject.class);
+ assertThat(actual).isSameInstanceAs(expected);
+ }
+
+ private static class TestXmlObject {}
+}