diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ChunkUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ChunkUploadRequest.java new file mode 100644 index 000000000000..7bc13eaa28e9 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ChunkUploadRequest.java @@ -0,0 +1,104 @@ +/* + * 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.resumableupload; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import com.google.protobuf.ByteString; +import org.jspecify.annotations.NullMarked; + +/** Request parameters for uploading an individual payload chunk. */ +@InternalApi +@NullMarked +@AutoValue +public abstract class ChunkUploadRequest { + + /** Returns the upload session URI. */ + public abstract String getUploadUrl(); + + /** Returns the byte payload to transmit in this chunk. */ + public abstract ByteString getPayload(); + + /** Returns the byte offset within the total stream where this chunk begins. */ + public abstract long getOffset(); + + /** Returns the total length of the upload payload in bytes, or -1 if unknown. */ + public abstract long getTotalLength(); + + /** Returns whether this chunk is the final chunk of the upload. */ + public abstract boolean isFinal(); + + public abstract Builder toBuilder(); + + public static Builder builder() { + return new AutoValue_ChunkUploadRequest.Builder() + .setOffset(0L) + .setTotalLength(-1L) + .setFinal(false); + } + + public static ChunkUploadRequest create( + String uploadUrl, ByteString payload, long offset, long totalLength, boolean isFinal) { + return builder() + .setUploadUrl(uploadUrl) + .setPayload(payload) + .setOffset(offset) + .setTotalLength(totalLength) + .setFinal(isFinal) + .build(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setUploadUrl(String uploadUrl); + + public abstract Builder setPayload(ByteString payload); + + public abstract Builder setOffset(long offset); + + public abstract Builder setTotalLength(long totalLength); + + public abstract Builder setFinal(boolean isFinal); + + abstract ChunkUploadRequest autoBuild(); + + public ChunkUploadRequest build() { + ChunkUploadRequest request = autoBuild(); + Preconditions.checkArgument(request.getOffset() >= 0, "offset must be non-negative"); + Preconditions.checkArgument(request.getTotalLength() >= -1, "totalLength must be >= -1"); + if (request.getTotalLength() >= 0) { + Preconditions.checkArgument( + request.getOffset() <= request.getTotalLength(), "offset must be <= totalLength"); + } + return request; + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ChunkUploadResponse.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ChunkUploadResponse.java new file mode 100644 index 000000000000..211fad5f9259 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ChunkUploadResponse.java @@ -0,0 +1,58 @@ +/* + * 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.resumableupload; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** Response received after uploading an individual payload chunk. */ +@InternalApi +@NullMarked +@AutoValue +public abstract class ChunkUploadResponse { + + /** Returns the byte offset confirmed by the server as successfully received. */ + public abstract long getCommittedOffset(); + + /** Returns whether the entire upload has completed. */ + public abstract boolean isComplete(); + + /** Returns the raw server response body (present on completion), or {@code null} if ongoing. */ + public abstract @Nullable String getResponseBody(); + + public static ChunkUploadResponse create( + long committedOffset, boolean isComplete, @Nullable String responseBody) { + Preconditions.checkArgument(committedOffset >= 0, "committedOffset must be non-negative"); + return new AutoValue_ChunkUploadResponse(committedOffset, isComplete, responseBody); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/QueryStatusRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/QueryStatusRequest.java new file mode 100644 index 000000000000..9c7df1eb6eb7 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/QueryStatusRequest.java @@ -0,0 +1,53 @@ +/* + * 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.resumableupload; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import org.jspecify.annotations.NullMarked; + +/** Request parameters for querying the current upload status of a session. */ +@InternalApi +@NullMarked +@AutoValue +public abstract class QueryStatusRequest { + + /** Returns the upload session URI. */ + public abstract String getUploadUrl(); + + /** Returns the total length of the upload payload in bytes, or -1 if unknown. */ + public abstract long getTotalLength(); + + public static QueryStatusRequest create(String uploadUrl, long totalLength) { + Preconditions.checkArgument(totalLength >= -1, "totalLength must be >= -1"); + return new AutoValue_QueryStatusRequest(uploadUrl, totalLength); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/QueryStatusResponse.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/QueryStatusResponse.java new file mode 100644 index 000000000000..0c3b75838995 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/QueryStatusResponse.java @@ -0,0 +1,62 @@ +/* + * 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.resumableupload; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** Response received from querying the server for current upload progress. */ +@InternalApi +@NullMarked +@AutoValue +public abstract class QueryStatusResponse { + + /** Returns the byte offset confirmed by the server as successfully received. */ + public abstract long getCommittedOffset(); + + /** Returns whether the entire upload has completed. */ + public abstract boolean isComplete(); + + /** Returns the raw server response body (present on completion), or {@code null} if ongoing. */ + public abstract @Nullable String getResponseBody(); + + public static QueryStatusResponse create(long committedOffset) { + return create(committedOffset, false, null); + } + + public static QueryStatusResponse create( + long committedOffset, boolean isComplete, @Nullable String responseBody) { + Preconditions.checkArgument(committedOffset >= 0, "committedOffset must be non-negative"); + return new AutoValue_QueryStatusResponse(committedOffset, isComplete, responseBody); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadClient.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadClient.java new file mode 100644 index 000000000000..72cc4aef4b5b --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadClient.java @@ -0,0 +1,49 @@ +/* + * 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.resumableupload; + +import com.google.api.core.InternalApi; +import com.google.api.gax.rpc.UnaryCallable; +import org.jspecify.annotations.NullMarked; + +/** An interface for executing low-level resumable upload operations. */ +@InternalApi +@NullMarked +public interface ResumableUploadClient { + + /** Returns a {@link UnaryCallable} to initiate a resumable upload session. */ + UnaryCallable startUploadCallable(); + + /** Returns a {@link UnaryCallable} to transmit an individual chunk. */ + UnaryCallable uploadChunkCallable(); + + /** Returns a {@link UnaryCallable} to query the server for current upload status. */ + UnaryCallable queryStatusCallable(); +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadSession.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadSession.java new file mode 100644 index 000000000000..cee196f14878 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/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.resumableupload; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import org.jspecify.annotations.NullMarked; + +/** Represents the negotiated session metadata returned after starting a resumable upload. */ +@InternalApi +@NullMarked +@AutoValue +public abstract class ResumableUploadSession { + + private static final long DEFAULT_CHUNK_GRANULARITY = 1L; + + /** Returns the server-provided URI 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 URI + * @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 URI + * @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/resumableupload/StartUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/StartUploadRequest.java new file mode 100644 index 000000000000..0c94a467075f --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/StartUploadRequest.java @@ -0,0 +1,102 @@ +/* + * 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.resumableupload; + +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 com.google.common.collect.Maps; +import java.util.Collections; +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. */ +@InternalApi +@NullMarked +@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. */ + public abstract @Nullable 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()); + } + + public static StartUploadRequest create(String path) { + return create(path, null, Collections.emptyMap()); + } + + public static StartUploadRequest create(String path, @Nullable String jsonPayload) { + return create(path, jsonPayload, Collections.emptyMap()); + } + + public static StartUploadRequest create( + String path, @Nullable String jsonPayload, Map> queryParams) { + return builder() + .setPath(path) + .setJsonPayload(jsonPayload) + .setQueryParams( + queryParams.isEmpty() + ? Collections.emptyMap() + : ImmutableMap.copyOf(Maps.transformValues(queryParams, ImmutableList::copyOf))) + .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 Map> getQueryParams(); + + abstract StartUploadRequest autoBuild(); + + public StartUploadRequest build() { + setQueryParams( + ImmutableMap.copyOf(Maps.transformValues(getQueryParams(), ImmutableList::copyOf))); + return autoBuild(); + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumableupload/ResumableUploadClientTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumableupload/ResumableUploadClientTest.java new file mode 100644 index 000000000000..0eb05f7e571e --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumableupload/ResumableUploadClientTest.java @@ -0,0 +1,150 @@ +/* + * 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.resumableupload; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.protobuf.ByteString; +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 ResumableUploadClientTest { + + 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 chunkUploadRequest_enforcesProtocolInvariants() { + // Valid: offset >= 0, totalLength == -1 (streaming/unknown) or >= 0 (known) + ChunkUploadRequest streamingRequest = + ChunkUploadRequest.create(UPLOAD_URL, ByteString.copyFromUtf8("data"), 0, -1, false); + assertThat(streamingRequest.getTotalLength()).isEqualTo(-1); + assertThat(streamingRequest.isFinal()).isFalse(); + + ChunkUploadRequest requestFromBuilder = + streamingRequest.toBuilder().setOffset(100L).setTotalLength(200L).setFinal(true).build(); + assertThat(requestFromBuilder.getOffset()).isEqualTo(100L); + assertThat(requestFromBuilder.getTotalLength()).isEqualTo(200L); + assertThat(requestFromBuilder.isFinal()).isTrue(); + + // Invalid: negative offset + assertThrows( + IllegalArgumentException.class, + () -> ChunkUploadRequest.create(UPLOAD_URL, ByteString.EMPTY, -1, 100, false)); + + // Invalid: totalLength < -1 + assertThrows( + IllegalArgumentException.class, + () -> ChunkUploadRequest.create(UPLOAD_URL, ByteString.EMPTY, 0, -2, false)); + + // Invalid: offset > totalLength when totalLength >= 0 + assertThrows( + IllegalArgumentException.class, + () -> ChunkUploadRequest.create(UPLOAD_URL, ByteString.EMPTY, 200, 100, false)); + } + + @Test + void queryStatusRequest_enforcesTotalLengthInvariants() { + QueryStatusRequest streamingRequest = QueryStatusRequest.create(UPLOAD_URL, -1L); + assertThat(streamingRequest.getTotalLength()).isEqualTo(-1L); + assertThat(streamingRequest.getUploadUrl()).isEqualTo(UPLOAD_URL); + + QueryStatusRequest knownLengthRequest = QueryStatusRequest.create(UPLOAD_URL, 5000L); + assertThat(knownLengthRequest.getTotalLength()).isEqualTo(5000L); + + assertThrows(IllegalArgumentException.class, () -> QueryStatusRequest.create(UPLOAD_URL, -2L)); + } + + @Test + void chunkAndStatusResponse_enforcesCommittedOffsetInvariants() { + assertThrows( + IllegalArgumentException.class, () -> ChunkUploadResponse.create(-1L, false, null)); + assertThrows(IllegalArgumentException.class, () -> QueryStatusResponse.create(-1L)); + assertThrows( + IllegalArgumentException.class, () -> QueryStatusResponse.create(-1L, false, null)); + + ChunkUploadResponse completed = ChunkUploadResponse.create(1024L, true, "{\"status\":\"ok\"}"); + assertThat(completed.isComplete()).isTrue(); + assertThat(completed.getResponseBody()).isEqualTo("{\"status\":\"ok\"}"); + + QueryStatusResponse statusOngoing = QueryStatusResponse.create(512L); + assertThat(statusOngoing.getCommittedOffset()).isEqualTo(512L); + assertThat(statusOngoing.isComplete()).isFalse(); + assertThat(statusOngoing.getResponseBody()).isNull(); + + QueryStatusResponse statusFinalized = + QueryStatusResponse.create(1024L, true, "{\"status\":\"ok\"}"); + assertThat(statusFinalized.getCommittedOffset()).isEqualTo(1024L); + assertThat(statusFinalized.isComplete()).isTrue(); + assertThat(statusFinalized.getResponseBody()).isEqualTo("{\"status\":\"ok\"}"); + } + + @Test + void startUploadRequest_guaranteesImmutabilityAndBuilderSupport() { + assertThat(StartUploadRequest.create("/v1/upload").getJsonPayload()).isNull(); + + Map> mutableParams = new HashMap<>(); + List mutableList = new ArrayList<>(); + mutableList.add("value1"); + mutableParams.put("key1", mutableList); + + StartUploadRequest request = StartUploadRequest.create("/v1/upload", "{}", mutableParams); + + // 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); + } +}