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 cd4bed572b4f..ee3c9f82e9e4 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 @@ -29,10 +29,15 @@ */ package com.google.api.gax.httpjson; +import com.google.api.client.http.ByteArrayContent; +import com.google.api.client.http.EmptyContent; +import com.google.api.client.http.HttpContent; import com.google.api.client.http.HttpMethods; import com.google.api.core.ApiFuture; import com.google.api.core.InternalApi; import com.google.api.core.SettableApiFuture; +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.resumable.StartUploadRequest; @@ -67,14 +72,20 @@ public final class HttpJsonResumableUploadClient implements ResumableUploadClien 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 START_UPLOAD_DESCRIPTOR = ApiMethodDescriptor.newBuilder() .setFullMethodName("ResumableUpload/StartUpload") @@ -99,7 +110,46 @@ public String getPath(StartUploadRequest request) { @Override public PathTemplate getPathTemplate() { - return PathTemplate.create("{+path}"); + return PATH_TEMPLATE; + } + }) + .setResponseParser(StringHttpResponseParser.create()) + .build(); + + private static final ApiMethodDescriptor UPLOAD_CHUNK_DESCRIPTOR = + ApiMethodDescriptor.newBuilder() + .setFullMethodName("ResumableUpload/UploadChunk") + .setHttpMethod(HttpMethods.POST) + .setType(ApiMethodDescriptor.MethodType.UNARY) + .setRequestFormatter( + new HttpRequestFormatter() { + @Override + public Map> getQueryParamNames(ChunkUploadRequest request) { + return Collections.emptyMap(); + } + + @Override + public String getRequestBody(ChunkUploadRequest request) { + return ""; + } + + @Override + public HttpContent getHttpContent(ChunkUploadRequest request) { + if (!request.getPayload().isEmpty()) { + return new ByteArrayContent( + "application/octet-stream", request.getPayload().toByteArray()); + } + return new EmptyContent(); + } + + @Override + public String getPath(ChunkUploadRequest request) { + return request.getUploadUrl(); + } + + @Override + public PathTemplate getPathTemplate() { + return PATH_TEMPLATE; } }) .setResponseParser(StringHttpResponseParser.create()) @@ -141,6 +191,45 @@ public ApiFuture futureCall( }; } + @Override + public UnaryCallable uploadChunkCallable() { + return new UnaryCallable() { + @Override + public ApiFuture futureCall( + ChunkUploadRequest request, @Nullable ApiCallContext inputContext) { + Preconditions.checkNotNull(request); + String command; + if (request.isFinal()) { + command = !request.getPayload().isEmpty() ? "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 = + (HttpJsonCallContext) + HttpJsonCallContext.createDefault() + .nullToSelf(clientContext.getDefaultCallContext()) + .merge(inputContext) + .withExtraHeaders(chunkHeaders); + + HttpJsonClientCall clientCall = + HttpJsonClientCalls.newCall(UPLOAD_CHUNK_DESCRIPTOR, context); + + SettableApiFuture future = SettableApiFuture.create(); + HttpJsonClientCalls.startUnaryCall( + clientCall, request, context, new ChunkUploadResponseListener(request, future)); + + return future; + } + }; + } + private static class StartUploadResponseListener extends HttpJsonClientCall.Listener { private final SettableApiFuture future; @@ -190,16 +279,7 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) { /* retryable= */ false)); } } else { - Throwable cause = trailers != null ? trailers.getException() : null; - ApiException apiException = - cause != null - ? API_EXCEPTION_FACTORY.create(cause) - : ApiExceptionFactory.createException( - "Failed to start upload with status code: " + statusCode, - /* cause= */ null, - HttpJsonStatusCode.of(statusCode), - /* retryable= */ false); - future.setException(apiException); + future.setException(createApiException(statusCode, trailers, "Failed to start upload")); } } catch (Throwable t) { future.setException( @@ -211,4 +291,84 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) { } } } + + private static class ChunkUploadResponseListener extends HttpJsonClientCall.Listener { + + private final ChunkUploadRequest request; + private final SettableApiFuture future; + private boolean isComplete = false; + private long committedOffset = -1L; + private String responseBody = ""; + + ChunkUploadResponseListener( + ChunkUploadRequest request, SettableApiFuture future) { + this.request = request; + this.future = future; + } + + @Override + public void onHeaders(HttpJsonMetadata responseHeaders) { + Map headers = responseHeaders.getHeaders(); + + String statusStr = HttpHeadersUtils.getFirstHeader(headers, UPLOAD_STATUS_HEADER); + if (STATUS_FINAL.equalsIgnoreCase(statusStr)) { + this.isComplete = true; + } + + String sizeReceivedStr = + HttpHeadersUtils.getFirstHeader(headers, UPLOAD_SIZE_RECEIVED_HEADER); + if (!Strings.isNullOrEmpty(sizeReceivedStr)) { + try { + this.committedOffset = Long.parseLong(sizeReceivedStr); + } catch (NumberFormatException ignored) { + // Ignore invalid/malformed size received header and fall back to local offset + // calculation. + } + } + } + + @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) { + long confirmedOffset = + committedOffset >= 0 + ? committedOffset + : request.getOffset() + request.getPayload().size(); + future.set( + ChunkUploadResponse.create( + confirmedOffset, isComplete, isComplete ? responseBody : "")); + } else { + future.setException(createApiException(statusCode, trailers, "Failed to upload chunk")); + } + } catch (Throwable t) { + future.setException( + ApiExceptionFactory.createException( + "Internal error processing upload chunk response", + t, + HttpJsonStatusCode.of(StatusCode.Code.INTERNAL), + /* retryable= */ false)); + } + } + } + + private static ApiException createApiException( + int statusCode, @Nullable HttpJsonMetadata trailers, String actionDescription) { + Throwable cause = trailers != null ? trailers.getException() : null; + if (cause != null) { + return API_EXCEPTION_FACTORY.create(cause); + } + return ApiExceptionFactory.createException( + actionDescription + " with status code: " + statusCode, + /* cause= */ null, + HttpJsonStatusCode.of(statusCode), + /* retryable= */ false); + } } 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 128fa1f882f6..dbdfcee45ab3 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 @@ -38,13 +38,18 @@ import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; +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.resumable.StartUploadRequest; +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.protobuf.ByteString; import java.io.IOException; import java.util.Collections; import java.util.HashMap; @@ -270,4 +275,233 @@ void startUpload_serverReturnsErrorWithoutException_throwsApiException() { assertThat(exception.getCause()).isInstanceOf(com.google.api.gax.rpc.ApiException.class); assertThat(exception.getCause()).hasMessageThat().contains("500"); } + + @Test + void uploadChunk_intermediateChunk_sendsUploadCommandAndReturnsActiveStatus() { + Map> capturedHeaders = new HashMap<>(); + String[] capturedUrl = new String[1]; + + HttpTransport httpTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + capturedUrl[0] = url; + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() { + capturedHeaders.putAll(getHeaders()); + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + response.addHeader("X-Goog-Upload-Status", "active"); + response.addHeader("X-Goog-Upload-Size-Received", "262144"); + return response; + } + }; + } + }; + + HttpJsonResumableUploadClient client = createClient(httpTransport); + ByteString payload = ByteString.copyFromUtf8("hello chunk data"); + ChunkUploadRequest request = + ChunkUploadRequest.create( + "https://test.googleapis.com/upload/session/123?upload_id=abc", payload, 0L, false); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.isComplete()).isFalse(); + assertThat(response.getCommittedOffset()).isEqualTo(262144L); + assertThat(response.getResponseBody()).isEmpty(); + + assertThat(capturedUrl[0]).contains("https://test.googleapis.com/upload/session/123"); + assertThat(capturedHeaders).containsKey("x-goog-upload-command"); + assertThat(capturedHeaders.get("x-goog-upload-command")).contains("upload"); + assertThat(capturedHeaders).containsKey("x-goog-upload-offset"); + assertThat(capturedHeaders.get("x-goog-upload-offset")).contains("0"); + } + + @Test + void uploadChunk_finalChunk_sendsUploadFinalizeAndReturnsResponseBody() { + Map> capturedHeaders = new HashMap<>(); + String[] capturedUrl = new String[1]; + + HttpTransport httpTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + capturedUrl[0] = url; + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() { + capturedHeaders.putAll(getHeaders()); + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + response.addHeader("X-Goog-Upload-Status", "final"); + response.addHeader("X-Goog-Upload-Size-Received", "524288"); + response.setContent("{\"name\":\"uploaded-file.txt\",\"size\":524288}"); + return response; + } + }; + } + }; + + HttpJsonResumableUploadClient client = createClient(httpTransport); + ByteString payload = ByteString.copyFromUtf8("final chunk data"); + ChunkUploadRequest request = + ChunkUploadRequest.create( + "https://test.googleapis.com/upload/session/123", payload, 262144L, true); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.isComplete()).isTrue(); + assertThat(response.getCommittedOffset()).isEqualTo(524288L); + assertThat(response.getResponseBody()) + .isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":524288}"); + + assertThat(capturedHeaders).containsKey("x-goog-upload-command"); + assertThat(capturedHeaders.get("x-goog-upload-command")).contains("upload, finalize"); + assertThat(capturedHeaders).containsKey("x-goog-upload-offset"); + assertThat(capturedHeaders.get("x-goog-upload-offset")).contains("262144"); + } + + @Test + void uploadChunk_emptyPayloadFinal_sendsFinalizeCommandAndReturnsResponseBody() { + Map> capturedHeaders = new HashMap<>(); + String[] capturedUrl = new String[1]; + + HttpTransport httpTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + capturedUrl[0] = url; + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() { + capturedHeaders.putAll(getHeaders()); + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + response.addHeader("X-Goog-Upload-Status", "final"); + response.addHeader("X-Goog-Upload-Size-Received", "524288"); + response.setContent("{\"name\":\"uploaded-file.txt\",\"size\":524288}"); + return response; + } + }; + } + }; + + HttpJsonResumableUploadClient client = createClient(httpTransport); + ChunkUploadRequest request = + ChunkUploadRequest.create( + "https://test.googleapis.com/upload/session/123", ByteString.EMPTY, 524288L, true); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.isComplete()).isTrue(); + assertThat(response.getCommittedOffset()).isEqualTo(524288L); + assertThat(response.getResponseBody()) + .isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":524288}"); + + assertThat(capturedHeaders).containsKey("x-goog-upload-command"); + assertThat(capturedHeaders.get("x-goog-upload-command")).contains("finalize"); + assertThat(capturedHeaders).containsKey("x-goog-upload-offset"); + assertThat(capturedHeaders.get("x-goog-upload-offset")).contains("524288"); + } + + @Test + void uploadChunk_withCustomExtraHeaders_preservesHeaders() { + Map> capturedHeaders = new HashMap<>(); + + HttpTransport httpTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() { + capturedHeaders.putAll(getHeaders()); + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + response.addHeader("X-Goog-Upload-Status", "active"); + response.addHeader("X-Goog-Upload-Size-Received", "100"); + return response; + } + }; + } + }; + + HttpJsonResumableUploadClient client = createClient(httpTransport); + ChunkUploadRequest request = + ChunkUploadRequest.create( + "https://test.googleapis.com/upload/session/123", ByteString.copyFromUtf8("data"), 0L); + + Map> customHeaders = new HashMap<>(); + customHeaders.put("X-Custom-Chunk-Header", Collections.singletonList("CustomChunkValue")); + + ApiCallContext callContext = + HttpJsonCallContext.createDefault().withExtraHeaders(customHeaders); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request, callContext); + + assertThat(response.getCommittedOffset()).isEqualTo(100L); + assertThat(capturedHeaders).containsKey("x-custom-chunk-header"); + assertThat(capturedHeaders.get("x-custom-chunk-header")).contains("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.create( + "https://test.googleapis.com/upload/session/123", + ByteString.copyFromUtf8("data"), + 100L); + + 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.create("https://test.googleapis.com/upload/session/123", payload, 50L); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.getCommittedOffset()).isEqualTo(60L); + assertThat(response.isComplete()).isFalse(); + assertThat(response.getResponseBody()).isEmpty(); + } + + + @Test + void uploadChunk_serverReturnsErrorWithoutException_throwsApiException() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(500); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + ChunkUploadRequest request = + ChunkUploadRequest.create( + "https://test.googleapis.com/upload/session/123", ByteString.copyFromUtf8("data"), 0L); + + ExecutionException exception = + assertThrows( + ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get()); + + assertThat(exception.getCause()).isInstanceOf(ApiException.class); + assertThat(exception.getCause()).hasMessageThat().contains("500"); + } } 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..6da876598f67 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadRequest.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 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 static ChunkUploadRequest create(String uploadUrl, ByteString payload, long offset) { + return create(uploadUrl, payload, offset, false); + } + + public static ChunkUploadRequest create( + String uploadUrl, ByteString payload, long offset, boolean isFinal) { + return new AutoValue_ChunkUploadRequest(uploadUrl, payload, offset, isFinal); + } +} 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..b33eb9ba5211 --- /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; + +/** Response value object representing the outcome of a chunk upload. */ +@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 body returned by the server upon final completion (e.g. JSON metadata of the + * uploaded resource), or an empty string if no body was returned or the upload is still in + * progress. + */ + public abstract String getResponseBody(); + + public static ChunkUploadResponse create(long committedOffset, boolean isComplete) { + return create(committedOffset, isComplete, ""); + } + + public static ChunkUploadResponse create( + long committedOffset, boolean isComplete, String responseBody) { + return new AutoValue_ChunkUploadResponse(committedOffset, isComplete, responseBody); + } +} 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 bb26f948d87b..7087239ee544 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 @@ -40,4 +40,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(); }