From eb753db8824b9253dd161df3defec342fba32ba6 Mon Sep 17 00:00:00 2001 From: whowes Date: Fri, 14 Aug 2026 23:53:09 +0000 Subject: [PATCH] feat(gax): implement uploadChunk in HttpJsonResumableUploadClient --- .../HttpJsonResumableUploadClient.java | 195 ++++++++++++++- .../HttpJsonResumableUploadClientTest.java | 227 ++++++++++++++++++ .../api/gax/resumable/ChunkUploadRequest.java | 73 ++++++ .../gax/resumable/ChunkUploadResponse.java | 67 ++++++ .../gax/resumable/ResumableUploadClient.java | 3 + .../gax/resumable/ChunkUploadRequestTest.java | 114 +++++++++ 6 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadRequest.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadResponse.java create mode 100644 sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadRequestTest.java 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 32fdb809ae77..1eb3b6e95e8c 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 @@ -33,6 +33,8 @@ import com.google.api.core.AbstractApiFuture; import com.google.api.core.ApiFuture; 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.ApiCallContext; @@ -40,10 +42,14 @@ 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.base.Strings; 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; @@ -65,16 +71,54 @@ public final class HttpJsonResumableUploadClient private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol"; 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_URL_HEADER = "X-Goog-Upload-URL"; private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity"; + private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status"; + private static final String UPLOAD_SIZE_RECEIVED_HEADER = "X-Goog-Upload-Size-Received"; + private static final String STATUS_FINAL = "final"; private static final Map> START_UPLOAD_HEADERS = ImmutableMap.of( UPLOAD_PROTOCOL_HEADER, ImmutableList.of("resumable"), UPLOAD_COMMAND_HEADER, ImmutableList.of("start")); + 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 ApiMethodDescriptor startUploadDescriptor; private final UnaryCallable startUploadCallable; + private final UnaryCallable> + uploadChunkCallable; public static HttpJsonResumableUploadClient create( ClientContext clientContext, ApiMethodDescriptor methodDescriptor) { @@ -85,6 +129,8 @@ private HttpJsonResumableUploadClient( ClientContext clientContext, ApiMethodDescriptor methodDescriptor) { Preconditions.checkNotNull(clientContext); Preconditions.checkNotNull(methodDescriptor); + HttpResponseParser responseParser = + Preconditions.checkNotNull(methodDescriptor.getResponseParser()); this.startUploadDescriptor = ApiMethodDescriptor.newBuilder() @@ -95,6 +141,7 @@ private HttpJsonResumableUploadClient( .setResponseParser(ResumableUploadResponseParser.create()) .build(); this.startUploadCallable = createStartUploadCallable(clientContext); + this.uploadChunkCallable = createUploadChunkCallable(clientContext, responseParser); } @Override @@ -102,6 +149,11 @@ public UnaryCallable startUploadCallable() { return startUploadCallable; } + @Override + public UnaryCallable> uploadChunkCallable() { + return uploadChunkCallable; + } + private UnaryCallable createStartUploadCallable( ClientContext clientContext) { UnaryCallable rawCallable = @@ -127,6 +179,49 @@ public ApiFuture futureCall( return createClientCallable(rawCallable, clientContext); } + private UnaryCallable> + createUploadChunkCallable( + ClientContext clientContext, HttpResponseParser responseParser) { + UnaryCallable> rawCallable = + new UnaryCallable>() { + @Override + public ApiFuture> futureCall( + ChunkUploadRequest request, @Nullable ApiCallContext inputContext) { + Preconditions.checkNotNull(request); + boolean isPayloadEmpty = request.getPayload().isEmpty(); + String command; + if (request.isFinal()) { + command = !isPayloadEmpty ? "upload, finalize" : "finalize"; + } else { + command = "upload"; + } + Map> chunkHeaders = + ImmutableMap.of( + UPLOAD_COMMAND_HEADER, + ImmutableList.of(command), + UPLOAD_OFFSET_HEADER, + ImmutableList.of(String.valueOf(request.getOffset()))); + + HttpJsonCallContext context = + createCallContext(clientContext, inputContext, chunkHeaders); + + HttpJsonClientCall clientCall = + HttpJsonClientCalls.newCall(UPLOAD_CHUNK_DESCRIPTOR, context); + + HttpJsonCallFuture> future = + new HttpJsonCallFuture<>(clientCall); + HttpJsonClientCalls.startUnaryCall( + clientCall, + request, + context, + new ChunkUploadResponseListener<>(request, future, responseParser)); + + return future; + } + }; + return createClientCallable(rawCallable, clientContext); + } + private static HttpJsonCallContext createCallContext( ClientContext clientContext, @Nullable ApiCallContext inputContext, @@ -148,6 +243,20 @@ private static UnaryCallable createCl return callable.withDefaultCallContext(clientContext.getDefaultCallContext()); } + @Nullable + private static Long parseSizeReceived(HttpJsonMetadata responseHeaders) { + String sizeReceivedStr = + HttpHeadersUtils.getSingleHeader(responseHeaders.getHeaders(), UPLOAD_SIZE_RECEIVED_HEADER); + if (!Strings.isNullOrEmpty(sizeReceivedStr)) { + try { + return Long.parseLong(sizeReceivedStr); + } catch (NumberFormatException ignored) { + // Unparseable header; return null and let the listener decide how to handle it. + } + } + return null; + } + /** * An {@link ApiFuture} that cancels the underlying {@link HttpJsonClientCall} to prevent * connection leaks. @@ -165,12 +274,10 @@ protected void interruptTask() { call.cancel("Call was cancelled", null); } - @Override public boolean set(T value) { return super.set(value); } - @Override public boolean setException(Throwable throwable) { return super.setException(throwable); } @@ -264,4 +371,88 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) { } } } + + /** + * A listener that processes chunk upload response headers and bodies to produce the {@link + * ChunkUploadResponse}. + */ + private static class ChunkUploadResponseListener + extends HttpJsonClientCall.Listener { + + private final ChunkUploadRequest request; + private final HttpJsonCallFuture> future; + private final HttpResponseParser responseParser; + private boolean hasUploadStatusHeader = false; + private boolean isComplete = false; + @Nullable private Long committedOffset = null; + private String responseBody = ""; + + ChunkUploadResponseListener( + ChunkUploadRequest request, + HttpJsonCallFuture> future, + HttpResponseParser responseParser) { + this.request = request; + this.future = future; + this.responseParser = responseParser; + } + + @Override + public void onHeaders(HttpJsonMetadata responseHeaders) { + Map headers = responseHeaders.getHeaders(); + + String statusStr = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER); + if (statusStr != null) { + this.hasUploadStatusHeader = true; + if (STATUS_FINAL.equalsIgnoreCase(statusStr)) { + this.isComplete = true; + } + } + + this.committedOffset = parseSizeReceived(responseHeaders); + } + + @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 (!hasUploadStatusHeader) { + future.setException( + ApiExceptionFactory.createException( + "Upload chunk response did not contain valid X-Goog-Upload-Status header", + /* cause= */ null, + HttpJsonStatusCode.of(StatusCode.Code.INTERNAL), + /* retryable= */ false)); + return; + } + long confirmedOffset = + committedOffset != null + ? committedOffset + : request.getOffset() + request.getPayload().size(); + ResponseT response = null; + if (isComplete) { + InputStream stream = + new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); + response = responseParser.parse(stream); + } + future.set(ChunkUploadResponse.create(confirmedOffset, isComplete, response)); + } 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 9971e054f8fa..81ab8d84aa3a 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; @@ -242,6 +247,228 @@ void startUpload_withCustomExtraHeaders_preservesHeaders() { } } + @Nested + class UploadChunk { + + @Test + void uploadChunk_intermediateChunk_sendsUploadCommandAndReturnsActiveStatus() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "active"); + httpResponse.addHeader("X-Goog-Upload-Size-Received", "262144"); + + CapturingHttpTransport transport = new CapturingHttpTransport(httpResponse); + HttpJsonResumableUploadClient client = createClient(transport); + + ByteString payload = ByteString.copyFromUtf8("hello chunk data"); + 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.getCommittedOffset()).isEqualTo(262144L); + 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.addHeader("X-Goog-Upload-Size-Received", "524288"); + httpResponse.setContent("{\"name\":\"uploaded-file.txt\",\"size\":524288}"); + + CapturingHttpTransport transport = new CapturingHttpTransport(httpResponse); + HttpJsonResumableUploadClient client = createClient(transport); + + ByteString payload = ByteString.copyFromUtf8("final chunk data"); + 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.getCommittedOffset()).isEqualTo(524288L); + 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.addHeader("X-Goog-Upload-Size-Received", "1048576"); + 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.getCommittedOffset()).isEqualTo(1048576L); + assertThat(response.getResponse()) + .isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":1048576}"); + + assertThat(transport.capturedHeaders.get("x-goog-upload-command")) + .containsExactly("finalize"); + assertThat(transport.capturedHeaders.get("x-goog-upload-offset")).containsExactly("1048576"); + } + + @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_missingSizeReceivedHeader_calculatesFromPayload() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "active"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + ByteString payload = ByteString.copyFromUtf8("1234567890"); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl(TEST_UPLOAD_URL) + .setPayload(payload) + .setOffset(50L) + .build(); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.getCommittedOffset()).isEqualTo(60L); + assertThat(response.isComplete()).isFalse(); + assertThat(response.getResponse()).isNull(); + } + + @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..2e3873929b13 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadRequest.java @@ -0,0 +1,73 @@ +/* + * 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.protobuf.ByteString; +import org.jspecify.annotations.NullMarked; + +/** Request value object for uploading a chunk to an active resumable upload session. */ +@NullMarked +@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..c08e607500c1 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadResponse.java @@ -0,0 +1,67 @@ +/* + * 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; +import org.jspecify.annotations.Nullable; + +/** + * Response value object representing the outcome of a chunk upload. + * + * @param response type of the upload operation + */ +@NullMarked +@InternalApi +@AutoValue +public abstract class ChunkUploadResponse { + + /** + * The total number of bytes successfully received and committed by the server so far. + * + *

This value is the starting offset for the next chunk upload. + */ + public abstract long getCommittedOffset(); + + /** 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 static ChunkUploadResponse create( + long committedOffset, boolean isComplete, @Nullable ResponseT response) { + return new AutoValue_ChunkUploadResponse<>(committedOffset, isComplete, response); + } +} 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..d867996344bb 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 @@ -45,4 +45,7 @@ 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/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..94be5cdc65ee --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadRequestTest.java @@ -0,0 +1,114 @@ +/* + * 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.protobuf.ByteString; +import org.junit.jupiter.api.Test; + +class ChunkUploadRequestTest { + + @Test + void builder_setsFieldsProperly() { + ByteString payload = ByteString.copyFromUtf8("test-payload"); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl("https://upload.example.com/session/1") + .setPayload(payload) + .setOffset(1024L) + .setFinal(true) + .build(); + + assertThat(request.getUploadUrl()).isEqualTo("https://upload.example.com/session/1"); + assertThat(request.getPayload()).isEqualTo(payload); + assertThat(request.getOffset()).isEqualTo(1024L); + assertThat(request.isFinal()).isTrue(); + } + + @Test + void builder_defaultIsFinalFalse() { + ByteString payload = ByteString.copyFromUtf8("test-payload"); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl("https://upload.example.com/session/1") + .setPayload(payload) + .setOffset(0L) + .build(); + + assertThat(request.getUploadUrl()).isEqualTo("https://upload.example.com/session/1"); + assertThat(request.getPayload()).isEqualTo(payload); + assertThat(request.getOffset()).isEqualTo(0L); + assertThat(request.isFinal()).isFalse(); + } + + @Test + void builder_nullUploadUrl_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> + ChunkUploadRequest.newBuilder() + .setUploadUrl(null) + .setPayload(ByteString.EMPTY) + .setOffset(0L) + .build()); + } + + @Test + void builder_nullPayload_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> + ChunkUploadRequest.newBuilder() + .setUploadUrl("https://upload.example.com/session/1") + .setPayload(null) + .setOffset(0L) + .build()); + } + + @Test + void toBuilder_preservesAndMutatesFields() { + ByteString payload = ByteString.copyFromUtf8("test-payload"); + ChunkUploadRequest request = + ChunkUploadRequest.newBuilder() + .setUploadUrl("https://upload.example.com/session/1") + .setPayload(payload) + .setOffset(0L) + .build(); + + ChunkUploadRequest updated = request.toBuilder().setOffset(1024L).setFinal(true).build(); + + assertThat(updated.getUploadUrl()).isEqualTo("https://upload.example.com/session/1"); + assertThat(updated.getPayload()).isEqualTo(payload); + assertThat(updated.getOffset()).isEqualTo(1024L); + assertThat(updated.isFinal()).isTrue(); + } +}