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..bb26f948d87b --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java @@ -0,0 +1,43 @@ +/* + * 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; +import org.jspecify.annotations.NullMarked; + +/** Client interface for executing low-level resumable upload operations. */ +@NullMarked +@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..654df6450fa1 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadSession.java @@ -0,0 +1,82 @@ +/* + * 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 org.jspecify.annotations.NullMarked; + +/** Represents the session metadata returned after starting a resumable upload. */ +@NullMarked +@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..7e26fa29cdce --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java @@ -0,0 +1,106 @@ +/* + * 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.List; +import java.util.Map; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** Request parameters for initiating a resumable upload session. */ +@NullMarked +@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 newBuilder() { + return new AutoValue_StartUploadRequest.Builder().setQueryParams(ImmutableMap.of()); + } + + /** + * 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 newBuilder().setPath(path).build(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setPath(String path); + + public abstract Builder setJsonPayload(@Nullable String jsonPayload); + + public abstract Builder setQueryParams(Map> queryParams); + + abstract @Nullable Map> getQueryParams(); + + abstract @Nullable 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(ImmutableMap.of()); + } + + return autoBuild(); + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadSessionTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadSessionTest.java new file mode 100644 index 000000000000..31162b99effd --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadSessionTest.java @@ -0,0 +1,68 @@ +/* + * 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 org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ResumableUploadSessionTest { + + private static final String UPLOAD_URL = "https://storage.googleapis.com/upload/session/12345"; + + @Test + void create_withUploadUrl_setsDefaultChunkGranularityToOneByte() { + ResumableUploadSession session = ResumableUploadSession.create(UPLOAD_URL); + + assertThat(session.getUploadUrl()).isEqualTo(UPLOAD_URL); + assertThat(session.getChunkGranularity()).isEqualTo(1L); + } + + @Test + void create_withExplicitChunkGranularity_preservesGranularity() { + long customChunkGranularity = 256 * 1024L; + + ResumableUploadSession session = + ResumableUploadSession.create(UPLOAD_URL, customChunkGranularity); + + assertThat(session.getUploadUrl()).isEqualTo(UPLOAD_URL); + assertThat(session.getChunkGranularity()).isEqualTo(customChunkGranularity); + } + + @ParameterizedTest + @ValueSource(longs = {0L, -1L, -100L}) + void create_withNonPositiveChunkGranularity_normalizesToOneByte(long invalidGranularity) { + ResumableUploadSession session = ResumableUploadSession.create(UPLOAD_URL, invalidGranularity); + + assertThat(session.getChunkGranularity()).isEqualTo(1L); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/StartUploadRequestTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/StartUploadRequestTest.java new file mode 100644 index 000000000000..7ac527556ae5 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/StartUploadRequestTest.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 static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.common.collect.ImmutableList; +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class StartUploadRequestTest { + + @ParameterizedTest + @CsvSource({"/v1/upload, v1/upload", "v1/upload, v1/upload", "/, ''"}) + void build_normalizesLeadingSlashInPath(String inputPath, String expectedPath) { + StartUploadRequest request = StartUploadRequest.newBuilder().setPath(inputPath).build(); + + assertThat(request.getPath()).isEqualTo(expectedPath); + } + + @Test + void create_setsPathAndDefaultParameters() { + StartUploadRequest request = StartUploadRequest.create("/v1/upload"); + + assertThat(request.getPath()).isEqualTo("v1/upload"); + assertThat(request.getQueryParams()).isEmpty(); + assertThat(request.getJsonPayload()).isNull(); + } + + @Test + void newBuilder_defaultsQueryParamsToEmptyMap() { + StartUploadRequest request = StartUploadRequest.newBuilder().setPath("v1/upload").build(); + + assertThat(request.getQueryParams()).isEmpty(); + assertThat(request.getJsonPayload()).isNull(); + } + + @Test + void newBuilder_defensivelyCopiesInputQueryParams() { + Map> mutableParams = new HashMap<>(); + List mutableList = new ArrayList<>(); + mutableList.add("value1"); + mutableParams.put("key1", mutableList); + + StartUploadRequest request = + StartUploadRequest.newBuilder().setPath("v1/upload").setQueryParams(mutableParams).build(); + + // Mutate source collections after build + mutableParams.put("key2", Collections.singletonList("value2")); + mutableList.add("value2"); + + assertThat(request.getQueryParams()).containsExactly("key1", ImmutableList.of("value1")); + } + + @Test + void getQueryParams_returnsUnmodifiableMapAndLists() { + Map> params = new HashMap<>(); + params.put("key1", new ArrayList<>(Collections.singletonList("value1"))); + + StartUploadRequest request = + StartUploadRequest.newBuilder().setPath("v1/upload").setQueryParams(params).build(); + + Map> queryParams = request.getQueryParams(); + List values = queryParams.get("key1"); + + assertThrows( + UnsupportedOperationException.class, + () -> queryParams.put("key2", Collections.singletonList("value2"))); + assertThrows(UnsupportedOperationException.class, () -> values.add("value2")); + } +}