diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java index d5df806e3046..ad260b20cdd7 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java @@ -32,6 +32,8 @@ import com.google.api.client.http.HttpMethods; import com.google.api.core.BetaApi; import com.google.api.core.InternalApi; +import com.google.api.gax.resumable.ChunkUploadRequest; +import com.google.api.gax.resumable.ChunkUploadResponse; import com.google.api.gax.resumable.ResumableUploadClient; import com.google.api.gax.resumable.ResumableUploadSession; import com.google.api.gax.rpc.ClientContext; @@ -54,6 +56,8 @@ public final class HttpJsonResumableUploadClient implements ResumableUploadClient { private final UnaryCallable startUploadCallable; + private final UnaryCallable> + uploadChunkCallable; public static HttpJsonResumableUploadClient create( ClientContext clientContext, ApiMethodDescriptor methodDescriptor) { @@ -64,6 +68,8 @@ private HttpJsonResumableUploadClient( ClientContext clientContext, ApiMethodDescriptor methodDescriptor) { Preconditions.checkNotNull(clientContext); Preconditions.checkNotNull(methodDescriptor); + HttpResponseParser responseParser = + Preconditions.checkNotNull(methodDescriptor.getResponseParser()); ApiMethodDescriptor startUploadDescriptor = ApiMethodDescriptor.newBuilder() @@ -75,10 +81,16 @@ private HttpJsonResumableUploadClient( .build(); this.startUploadCallable = ResumableUploadStartCallable.create(clientContext, startUploadDescriptor); + this.uploadChunkCallable = ResumableUploadChunkCallable.create(clientContext, responseParser); } @Override public UnaryCallable startUploadCallable() { return startUploadCallable; } + + @Override + public UnaryCallable> uploadChunkCallable() { + return uploadChunkCallable; + } } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadChunkCallable.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadChunkCallable.java new file mode 100644 index 000000000000..038d51512318 --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadChunkCallable.java @@ -0,0 +1,228 @@ +/* + * 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.httpjson; + +import com.google.api.client.http.HttpMethods; +import com.google.api.core.ApiFuture; +import com.google.api.gax.resumable.ChunkUploadRequest; +import com.google.api.gax.resumable.ChunkUploadResponse; +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ApiExceptionFactory; +import com.google.api.gax.rpc.ClientContext; +import com.google.api.gax.rpc.StatusCode; +import com.google.api.gax.rpc.UnaryCallable; +import com.google.api.pathtemplate.PathTemplate; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** A {@link UnaryCallable} that transmits individual chunks in a resumable upload session. */ +@NullMarked +class ResumableUploadChunkCallable + extends UnaryCallable> { + + private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command"; + private static final String UPLOAD_OFFSET_HEADER = "X-Goog-Upload-Offset"; + private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status"; + private static final String STATUS_FINAL = "final"; + + private static final String COMMAND_UPLOAD = "upload"; + private static final String COMMAND_FINALIZE = "finalize"; + private static final String COMMAND_UPLOAD_FINALIZE = "upload, finalize"; + + private static final PathTemplate PATH_TEMPLATE = PathTemplate.create("{+path}"); + + private static final ApiMethodDescriptor UPLOAD_CHUNK_DESCRIPTOR = + ApiMethodDescriptor.newBuilder() + .setFullMethodName("ResumableUpload/UploadChunk") + .setHttpMethod(HttpMethods.POST) + .setType(ApiMethodDescriptor.MethodType.UNARY) + .setRequestFormatter( + new ResumableUploadChunkRequestFormatter() { + @Override + public Map> getQueryParamNames(ChunkUploadRequest request) { + return Collections.emptyMap(); + } + + @Override + public byte[] getBinaryRequestBody(ChunkUploadRequest request) { + return request.getPayload().toByteArray(); + } + + @Override + public String getPath(ChunkUploadRequest request) { + return request.getUploadUrl(); + } + + @Override + public PathTemplate getPathTemplate() { + return PATH_TEMPLATE; + } + }) + .setResponseParser(ResumableUploadResponseParser.create()) + .build(); + + private final ClientContext clientContext; + private final HttpResponseParser responseParser; + + ResumableUploadChunkCallable( + ClientContext clientContext, HttpResponseParser responseParser) { + this.clientContext = Preconditions.checkNotNull(clientContext); + this.responseParser = Preconditions.checkNotNull(responseParser); + } + + @Override + public ApiFuture> futureCall( + ChunkUploadRequest request, @Nullable ApiCallContext inputContext) { + Preconditions.checkNotNull(request); + boolean isPayloadEmpty = request.getPayload().isEmpty(); + String command; + if (request.isFinal()) { + command = !isPayloadEmpty ? COMMAND_UPLOAD_FINALIZE : COMMAND_FINALIZE; + } else { + command = COMMAND_UPLOAD; + } + ImmutableMap.Builder> chunkHeadersBuilder = + ImmutableMap.>builder() + .put(UPLOAD_COMMAND_HEADER, ImmutableList.of(command)); + if (!COMMAND_FINALIZE.equals(command)) { + chunkHeadersBuilder.put( + UPLOAD_OFFSET_HEADER, ImmutableList.of(String.valueOf(request.getOffset()))); + } + Map> chunkHeaders = chunkHeadersBuilder.build(); + + HttpJsonCallContext context = + (HttpJsonCallContext) + HttpJsonCallContext.createDefault() + .nullToSelf(clientContext.getDefaultCallContext()) + .merge(inputContext) + .withExtraHeaders(chunkHeaders); + + HttpJsonClientCall clientCall = + HttpJsonClientCalls.newCall(UPLOAD_CHUNK_DESCRIPTOR, context); + + ResumableUploadHttpJsonFuture> future = + new ResumableUploadHttpJsonFuture<>(clientCall); + HttpJsonClientCalls.startUnaryCall( + clientCall, request, context, new ChunkUploadResponseListener<>(future, responseParser)); + + return future; + } + + static UnaryCallable> create( + ClientContext clientContext, HttpResponseParser responseParser) { + UnaryCallable> rawCallable = + new ResumableUploadChunkCallable<>(clientContext, responseParser); + UnaryCallable> callable = + new HttpJsonExceptionCallable<>( + rawCallable, + // Wire calls do not retry directly; retries are managed by ResumableUploadCallable. + Collections.emptySet()); + return callable.withDefaultCallContext(clientContext.getDefaultCallContext()); + } + + /** + * A listener that processes chunk upload response headers and bodies to produce the {@link + * ChunkUploadResponse}. + */ + private static class ChunkUploadResponseListener + extends HttpJsonClientCall.Listener { + + private final ResumableUploadHttpJsonFuture> future; + private final HttpResponseParser responseParser; + @Nullable private String uploadStatus = null; + private String responseBody = ""; + + ChunkUploadResponseListener( + ResumableUploadHttpJsonFuture> future, + HttpResponseParser responseParser) { + this.future = future; + this.responseParser = responseParser; + } + + @Override + public void onHeaders(HttpJsonMetadata responseHeaders) { + Map headers = responseHeaders.getHeaders(); + this.uploadStatus = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER); + } + + @Override + public void onMessage(@Nullable String message) { + if (message != null) { + this.responseBody = message; + } + } + + @Override + public void onClose(int statusCode, HttpJsonMetadata trailers) { + try { + if (statusCode >= 200 && statusCode < 300) { + if (uploadStatus == null) { + future.setException( + ApiExceptionFactory.createException( + "Upload chunk response did not contain valid " + + UPLOAD_STATUS_HEADER + + " header", + /* cause= */ null, + HttpJsonStatusCode.of(StatusCode.Code.INTERNAL), + /* retryable= */ false)); + return; + } + boolean isComplete = STATUS_FINAL.equalsIgnoreCase(uploadStatus); + ChunkUploadResponse.Builder chunkResponseBuilder = + ChunkUploadResponse.newBuilder().setComplete(isComplete); + if (isComplete) { + InputStream stream = + new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); + chunkResponseBuilder.setResponse(responseParser.parse(stream)); + } + future.set(chunkResponseBuilder.build()); + } else { + Throwable cause = trailers.getException(); + future.setException( + cause != null + ? cause + : new HttpJsonStatusRuntimeException( + statusCode, "Failed to upload chunk with status code: " + statusCode, null)); + } + } catch (Throwable t) { + future.setException(t); + } + } + } +} diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java index c3f856560c9c..02f869ad8140 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java @@ -40,14 +40,19 @@ import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; import com.google.api.core.InternalApi; +import com.google.api.gax.resumable.ChunkUploadRequest; +import com.google.api.gax.resumable.ChunkUploadResponse; import com.google.api.gax.resumable.ResumableUploadSession; +import com.google.api.gax.rpc.AbortedException; import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.ClientContext; import com.google.api.gax.rpc.InternalException; import com.google.api.gax.rpc.NotFoundException; import com.google.api.gax.rpc.StatusCode; import com.google.api.pathtemplate.PathTemplate; import com.google.common.base.Strings; +import com.google.protobuf.ByteString; import java.io.IOException; import java.util.Collections; import java.util.HashMap; @@ -233,6 +238,192 @@ void startUpload_withCustomExtraHeaders_preservesHeaders() { assertThat(transport.capturedHeaders.get("x-custom-header")).containsExactly("CustomValue"); } + @Test + void uploadChunk_intermediateChunk_sendsUploadCommandAndReturnsActiveStatus() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "active"); + + CapturingHttpTransport transport = new CapturingHttpTransport(httpResponse); + HttpJsonResumableUploadClient client = createClient(transport); + + ByteString payload = ByteString.copyFrom(new byte[262144]); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl(TEST_UPLOAD_URL) + .setPayload(payload) + .setOffset(0L) + .setFinal(false) + .build(); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.isComplete()).isFalse(); + assertThat(response.getResponse()).isNull(); + + assertThat(transport.capturedUrl).isEqualTo(TEST_UPLOAD_URL); + assertThat(transport.capturedHeaders.get("x-goog-upload-command")).containsExactly("upload"); + assertThat(transport.capturedHeaders.get("x-goog-upload-offset")).containsExactly("0"); + } + + @Test + void uploadChunk_finalChunk_sendsUploadFinalizeAndReturnsResponseBody() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "final"); + httpResponse.setContent("{\"name\":\"uploaded-file.txt\",\"size\":524288}"); + + CapturingHttpTransport transport = new CapturingHttpTransport(httpResponse); + HttpJsonResumableUploadClient client = createClient(transport); + + ByteString payload = ByteString.copyFrom(new byte[262144]); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl(TEST_UPLOAD_URL) + .setPayload(payload) + .setOffset(262144L) + .setFinal(true) + .build(); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.isComplete()).isTrue(); + assertThat(response.getResponse()) + .isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":524288}"); + + assertThat(transport.capturedHeaders.get("x-goog-upload-command")) + .containsExactly("upload, finalize"); + assertThat(transport.capturedHeaders.get("x-goog-upload-offset")).containsExactly("262144"); + } + + @Test + void uploadChunk_emptyPayloadFinal_sendsFinalizeCommandAndReturnsResponseBody() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "final"); + httpResponse.setContent("{\"name\":\"uploaded-file.txt\",\"size\":1048576}"); + + CapturingHttpTransport transport = new CapturingHttpTransport(httpResponse); + HttpJsonResumableUploadClient client = createClient(transport); + + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl(TEST_UPLOAD_URL) + .setPayload(ByteString.EMPTY) + .setOffset(1048576L) + .setFinal(true) + .build(); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.isComplete()).isTrue(); + assertThat(response.getResponse()) + .isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":1048576}"); + + assertThat(transport.capturedHeaders.get("x-goog-upload-command")).containsExactly("finalize"); + assertThat(transport.capturedHeaders).doesNotContainKey("x-goog-upload-offset"); + } + + @Test + void uploadChunk_withCustomExtraHeaders_preservesHeaders() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "active"); + + CapturingHttpTransport transport = new CapturingHttpTransport(httpResponse); + HttpJsonResumableUploadClient client = createClient(transport); + + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl(TEST_UPLOAD_URL) + .setPayload(ByteString.copyFromUtf8("data")) + .setOffset(0L) + .build(); + + Map> customHeaders = + Collections.singletonMap( + "X-Custom-Chunk-Header", Collections.singletonList("CustomChunkValue")); + + ApiCallContext callContext = + HttpJsonCallContext.createDefault().withExtraHeaders(customHeaders); + + client.uploadChunkCallable().call(request, callContext); + + assertThat(transport.capturedHeaders.get("x-custom-chunk-header")) + .containsExactly("CustomChunkValue"); + } + + @Test + void uploadChunk_serverReturnsConflictOrError_throwsException() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(409); + httpResponse.setContent("{\"error\":{\"message\":\"Invalid offset\"}}"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl(TEST_UPLOAD_URL) + .setPayload(ByteString.copyFromUtf8("data")) + .setOffset(100L) + .build(); + + ExecutionException exception = + assertThrows( + ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get()); + + assertThat(exception.getCause()).isInstanceOf(AbortedException.class); + AbortedException abortedException = (AbortedException) exception.getCause(); + assertThat(abortedException.getStatusCode().getCode()).isEqualTo(StatusCode.Code.ABORTED); + } + + @Test + void uploadChunk_missingUploadStatusHeader_throwsInternalException() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl(TEST_UPLOAD_URL) + .setPayload(ByteString.copyFromUtf8("data")) + .setOffset(0L) + .build(); + + ExecutionException exception = + assertThrows( + ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get()); + + assertThat(exception.getCause()).isInstanceOf(InternalException.class); + assertThat(exception.getCause()) + .hasMessageThat() + .contains("Upload chunk response did not contain valid X-Goog-Upload-Status header"); + } + + @Test + void uploadChunk_serverReturnsFinalStatusOnNon200_marksExceptionNonRetryable() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(503); + httpResponse.addHeader("X-Goog-Upload-Status", "final"); + httpResponse.setContent("{\"error\":{\"message\":\"Upload rejected by backend\"}}"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl(TEST_UPLOAD_URL) + .setPayload(ByteString.copyFromUtf8("data")) + .setOffset(0L) + .build(); + + ExecutionException exception = + assertThrows( + ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get()); + + assertThat(exception.getCause()).isInstanceOf(ApiException.class); + ApiException apiException = (ApiException) exception.getCause(); + assertThat(apiException.isRetryable()).isFalse(); + assertThat(apiException.getStatusCode().getCode()).isEqualTo(StatusCode.Code.UNAVAILABLE); + } + private static HttpJsonResumableUploadClient createClient( HttpTransport transport) { ManagedHttpJsonChannel channel = diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadRequest.java new file mode 100644 index 000000000000..deb8e51facfc --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadRequest.java @@ -0,0 +1,75 @@ +/* + * 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.BetaApi; +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import com.google.protobuf.ByteString; +import org.jspecify.annotations.NullMarked; + +/** Request value object for uploading a chunk to an active resumable upload session. */ +@NullMarked +@BetaApi +@InternalApi +@AutoValue +public abstract class ChunkUploadRequest { + + /** The upload session URL returned during session initialization. */ + public abstract String getUploadUrl(); + + /** The binary chunk payload to upload. */ + public abstract ByteString getPayload(); + + /** The byte offset of this chunk in the overall stream. */ + public abstract long getOffset(); + + /** Whether this is the final chunk in the stream. */ + public abstract boolean isFinal(); + + public abstract Builder toBuilder(); + + public static Builder newBuilder() { + return new AutoValue_ChunkUploadRequest.Builder().setFinal(false); + } + + @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 setFinal(boolean isFinal); + + public abstract ChunkUploadRequest build(); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadResponse.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadResponse.java new file mode 100644 index 000000000000..26066a6b19de --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadResponse.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.BetaApi; +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Response value object representing the outcome of a chunk upload. + * + * @param response type of the upload operation + */ +@NullMarked +@BetaApi +@InternalApi +@AutoValue +public abstract class ChunkUploadResponse { + + /** Whether the overall resumable upload stream has finalized and completed on the server. */ + public abstract boolean isComplete(); + + /** + * The response object returned by the server upon final completion (e.g. metadata of the uploaded + * resource), or {@code null} if the upload is still in progress. + */ + public abstract @Nullable ResponseT getResponse(); + + public abstract Builder toBuilder(); + + public static Builder newBuilder() { + return new AutoValue_ChunkUploadResponse.Builder().setComplete(false); + } + + public static ChunkUploadResponse create( + boolean isComplete, @Nullable ResponseT response) { + return new AutoValue_ChunkUploadResponse.Builder() + .setComplete(isComplete) + .setResponse(response) + .build(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setComplete(boolean isComplete); + + public abstract Builder setResponse(@Nullable ResponseT response); + + public abstract ChunkUploadResponse build(); + } +} 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 index d8913a1ab659..f1738bc7100c 100644 --- 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 @@ -29,6 +29,7 @@ */ package com.google.api.gax.resumable; +import com.google.api.core.BetaApi; import com.google.api.core.InternalApi; import com.google.api.gax.rpc.UnaryCallable; import org.jspecify.annotations.NullMarked; @@ -40,9 +41,13 @@ * @param response type of the upload operation */ @NullMarked +@BetaApi @InternalApi 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(); } 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 index d6d82d8e7b0f..345432957aa8 100644 --- 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 @@ -29,12 +29,14 @@ */ package com.google.api.gax.resumable; +import com.google.api.core.BetaApi; 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 +@BetaApi @InternalApi @AutoValue public abstract class ResumableUploadSession { diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadRequestTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadRequestTest.java new file mode 100644 index 000000000000..a2e4335bd0a0 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadRequestTest.java @@ -0,0 +1,63 @@ +/* + * 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 com.google.protobuf.ByteString; +import org.junit.jupiter.api.Test; + +class ChunkUploadRequestTest { + + @Test + void builder_defaultIsFinalFalse() { + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl("https://upload.example.com/session/1") + .setPayload(ByteString.copyFromUtf8("test-payload")) + .setOffset(0L) + .build(); + + assertThat(request.isFinal()).isFalse(); + } + + @Test + void builder_explicitIsFinalTrue_preservesValue() { + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl("https://upload.example.com/session/1") + .setPayload(ByteString.EMPTY) + .setOffset(1024L) + .setFinal(true) + .build(); + + assertThat(request.isFinal()).isTrue(); + } +}