From c07069bc22349d7e2957b81565870964e814676b Mon Sep 17 00:00:00 2001 From: whowes Date: Fri, 14 Aug 2026 20:07:34 +0000 Subject: [PATCH] feat(gax): add ResumableUploadClient SPI and types --- .../gax/resumable/ResumableUploadClient.java | 41 +++++++ .../gax/resumable/ResumableUploadSession.java | 80 +++++++++++++ .../api/gax/resumable/StartUploadRequest.java | 105 ++++++++++++++++++ .../resumable/ResumableUploadTypesTest.java | 91 +++++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadSession.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java create mode 100644 sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadTypesTest.java diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java new file mode 100644 index 000000000000..aa96451ad333 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumable; + +import com.google.api.core.InternalApi; +import com.google.api.gax.rpc.UnaryCallable; + +/** Client interface for executing low-level resumable upload operations. */ +@InternalApi +public interface ResumableUploadClient { + + /** Returns a {@link UnaryCallable} to initiate a resumable upload session. */ + UnaryCallable startUploadCallable(); +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadSession.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadSession.java new file mode 100644 index 000000000000..cb66afaecd91 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadSession.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumable; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; + +/** Represents the session metadata returned after starting a resumable upload. */ +@InternalApi +@AutoValue +public abstract class ResumableUploadSession { + + private static final long DEFAULT_CHUNK_GRANULARITY = 1L; + + /** Returns the server-provided URL to which data uploads are directed. */ + public abstract String getUploadUrl(); + + /** + * Returns the server-mandated chunk granularity in bytes. + * + *

When specified by the server (via {@code X-Goog-Upload-Chunk-Granularity}), intermediate + * upload chunks must have a size and offset that are an exact multiple of this value (the final + * chunk may be smaller). If not specified by the server, this defaults to 1 byte, indicating no + * alignment or granularity requirements apply. + * + * @return the chunk granularity in bytes + */ + public abstract long getChunkGranularity(); + + /** + * Creates a {@link ResumableUploadSession} with the specified upload URL and default chunk + * granularity. + * + * @param uploadUrl the upload session URL + * @return a new {@link ResumableUploadSession} instance + */ + public static ResumableUploadSession create(String uploadUrl) { + return create(uploadUrl, DEFAULT_CHUNK_GRANULARITY); + } + + /** + * Creates a {@link ResumableUploadSession} with the specified upload URL and chunk granularity. + * + * @param uploadUrl the upload session URL + * @param chunkGranularity the chunk granularity in bytes; if ≤ 0, 1 is used to indicate no + * alignment or granularity requirements apply. + * @return a new {@link ResumableUploadSession} instance + */ + public static ResumableUploadSession create(String uploadUrl, long chunkGranularity) { + return new AutoValue_ResumableUploadSession( + uploadUrl, chunkGranularity > 0 ? chunkGranularity : DEFAULT_CHUNK_GRANULARITY); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java new file mode 100644 index 000000000000..c5d02652bb0d --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumable; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Request parameters for initiating a resumable upload session. */ +@InternalApi +@AutoValue +public abstract class StartUploadRequest { + + /** Returns the URL path to append to the service endpoint. */ + public abstract String getPath(); + + /** Returns the optional initial JSON request payload. */ + @Nullable + public abstract String getJsonPayload(); + + /** Returns the query parameters for the initiation request. */ + public abstract Map> getQueryParams(); + + public abstract Builder toBuilder(); + + public static Builder builder() { + return new AutoValue_StartUploadRequest.Builder().setQueryParams(Collections.emptyMap()); + } + + /** + * Convenience factory for creating a {@link StartUploadRequest} with only a target path. + * + * @param path the resource upload path + * @return a new {@link StartUploadRequest} instance + */ + public static StartUploadRequest create(String path) { + return builder().setPath(path).build(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setPath(String path); + + public abstract Builder setJsonPayload(String jsonPayload); + + public abstract Builder setQueryParams(Map> queryParams); + + abstract Map> getQueryParams(); + + abstract String getPath(); + + abstract StartUploadRequest autoBuild(); + + public StartUploadRequest build() { + if (getPath() != null && getPath().startsWith("/")) { + setPath(getPath().substring(1)); + } + + Map> params = getQueryParams(); + if (params != null && !params.isEmpty()) { + ImmutableMap.Builder> mapBuilder = ImmutableMap.builder(); + for (Map.Entry> entry : params.entrySet()) { + mapBuilder.put(entry.getKey(), ImmutableList.copyOf(entry.getValue())); + } + setQueryParams(mapBuilder.build()); + } else { + setQueryParams(Collections.emptyMap()); + } + + return autoBuild(); + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadTypesTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadTypesTest.java new file mode 100644 index 000000000000..fe4c788419b1 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadTypesTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumable; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ResumableUploadTypesTest { + + private static final String UPLOAD_URL = "https://storage.googleapis.com/upload/session/12345"; + + @Test + void session_normalizesInvalidChunkGranularityToDefault() { + assertThat(ResumableUploadSession.create(UPLOAD_URL).getChunkGranularity()).isEqualTo(1L); + assertThat(ResumableUploadSession.create(UPLOAD_URL, 0).getChunkGranularity()).isEqualTo(1L); + assertThat(ResumableUploadSession.create(UPLOAD_URL, -100L).getChunkGranularity()) + .isEqualTo(1L); + assertThat(ResumableUploadSession.create(UPLOAD_URL, 256 * 1024L).getChunkGranularity()) + .isEqualTo(256 * 1024L); + } + + @Test + void startUploadRequest_guaranteesImmutabilityAndBuilderSupport() { + StartUploadRequest requestWithLeadingSlash = StartUploadRequest.create("/v1/upload"); + assertThat(requestWithLeadingSlash.getPath()).isEqualTo("v1/upload"); + assertThat(requestWithLeadingSlash.getJsonPayload()).isNull(); + + Map> mutableParams = new HashMap<>(); + List mutableList = new ArrayList<>(); + mutableList.add("value1"); + mutableParams.put("key1", mutableList); + + StartUploadRequest request = + StartUploadRequest.builder() + .setPath("/v1/upload") + .setJsonPayload("{}") + .setQueryParams(mutableParams) + .build(); + assertThat(request.getPath()).isEqualTo("v1/upload"); + + // Mutate source map and list after construction + mutableParams.put("key2", Collections.singletonList("value2")); + mutableList.add("value2"); + + assertThat(request.getQueryParams()).hasSize(1); + assertThat(request.getQueryParams().get("key1")).containsExactly("value1"); + assertThrows( + UnsupportedOperationException.class, + () -> request.getQueryParams().put("key3", Collections.singletonList("value3"))); + + StartUploadRequest mutatedFromBuilder = + request.toBuilder().setPath("/v2/upload").setJsonPayload("{\"updated\":true}").build(); + assertThat(mutatedFromBuilder.getPath()).isEqualTo("v2/upload"); + assertThat(mutatedFromBuilder.getJsonPayload()).isEqualTo("{\"updated\":true}"); + assertThat(mutatedFromBuilder.getQueryParams()).hasSize(1); + } +}