diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml index 1b8cb3e8f..855a1f999 100644 --- a/google-cloud-storage/pom.xml +++ b/google-cloud-storage/pom.xml @@ -19,6 +19,10 @@ 1.123.5 + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + 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 {} +}