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 3587d44dcfbd..1266a5be7934 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,8 +72,15 @@ 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"; + + /** HTTP status code 308 (Resume Incomplete in Google Scotty resumable upload protocol). */ + private static final int HTTP_STATUS_RESUME_INCOMPLETE = 308; private static final Map> START_UPLOAD_HEADERS = ImmutableMap.of( @@ -105,6 +117,45 @@ public PathTemplate getPathTemplate() { .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 PathTemplate.create("{+path}"); + } + }) + .setResponseParser(StringHttpResponseParser.create()) + .build(); + private final ClientContext clientContext; public static HttpJsonResumableUploadClient create(ClientContext clientContext) { @@ -141,6 +192,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; @@ -205,4 +295,81 @@ 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) { + } + } + } + + @Override + public void onMessage(@Nullable String message) { + if (message != null) { + this.responseBody = message; + } + } + + @Override + public void onClose(int statusCode, HttpJsonMetadata trailers) { + if ((statusCode >= 200 && statusCode < 300) + || statusCode == HTTP_STATUS_RESUME_INCOMPLETE) { + if (statusCode == HTTP_STATUS_RESUME_INCOMPLETE && committedOffset < 0) { + future.setException( + ApiExceptionFactory.createException( + "Server returned 308 Resume Incomplete but the " + + UPLOAD_SIZE_RECEIVED_HEADER + + " header was missing or invalid", + /* cause= */ null, + HttpJsonStatusCode.of(statusCode), + /* retryable= */ false)); + return; + } + long confirmedOffset = + committedOffset >= 0 + ? committedOffset + : request.getOffset() + request.getPayload().size(); + future.set( + ChunkUploadResponse.create( + confirmedOffset, isComplete, isComplete ? responseBody : "")); + } else { + Throwable cause = trailers.getException(); + ApiException apiException = + cause != null + ? API_EXCEPTION_FACTORY.create(cause) + : ApiExceptionFactory.createException( + "Failed to upload chunk with status code: " + statusCode, + /* cause= */ null, + HttpJsonStatusCode.of(statusCode), + /* retryable= */ false); + future.setException(apiException); + } + } + } } 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 65d1fa2b6e10..d5a7c179b6dc 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.ApiCallContext; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.AbortedException; 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; @@ -268,4 +273,256 @@ public LowLevelHttpResponse execute() { assertThat(capturedHeaders).containsKey("x-goog-upload-protocol"); assertThat(capturedHeaders.get("x-goog-upload-protocol")).contains("resumable"); } + + @Test + void uploadChunk_intermediateChunk_sendsUploadCommandAndReturnsActiveStatus() throws Exception { + 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() throws Exception { + 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() throws Exception { + 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_status308ResumeIncomplete_succeedsAndReturnsCommittedOffset() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(308); + httpResponse.addHeader("X-Goog-Upload-Status", "active"); + httpResponse.addHeader("X-Goog-Upload-Size-Received", "524288"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + ByteString payload = ByteString.copyFrom(new byte[262144]); + ChunkUploadRequest request = + ChunkUploadRequest.create( + "https://test.googleapis.com/upload/session/123", payload, 262144L); + + ChunkUploadResponse response = client.uploadChunkCallable().call(request); + + assertThat(response.isComplete()).isFalse(); + assertThat(response.getCommittedOffset()).isEqualTo(524288L); + assertThat(response.getResponseBody()).isEmpty(); + } + + @Test + void uploadChunk_status308MissingSizeReceivedHeader_throwsException() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(308); + httpResponse.addHeader("X-Goog-Upload-Status", "active"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + ByteString payload = ByteString.copyFrom(new byte[262144]); + ChunkUploadRequest request = + ChunkUploadRequest.create( + "https://test.googleapis.com/upload/session/123", payload, 262144L); + + ExecutionException exception = + assertThrows( + ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get()); + + assertThat(exception.getCause()).isInstanceOf(ApiException.class); + assertThat(exception.getCause()) + .hasMessageThat() + .contains("Server returned 308 Resume Incomplete but the X-Goog-Upload-Size-Received header was missing or invalid"); + } } 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..e52e14ebca7e --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadRequest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumable; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +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 Builder newBuilder() { + return new AutoValue_ChunkUploadRequest.Builder().setFinal(false); + } + + public static ChunkUploadRequest create(String uploadUrl, ByteString payload, long offset) { + return newBuilder() + .setUploadUrl(uploadUrl) + .setPayload(payload) + .setOffset(offset) + .setFinal(false) + .build(); + } + + public static ChunkUploadRequest create( + String uploadUrl, ByteString payload, long offset, boolean isFinal) { + return newBuilder() + .setUploadUrl(uploadUrl) + .setPayload(payload) + .setOffset(offset) + .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 setFinal(boolean isFinal); + + abstract ChunkUploadRequest autoBuild(); + + public ChunkUploadRequest build() { + ChunkUploadRequest request = autoBuild(); + Preconditions.checkArgument(request.getOffset() >= 0, "offset must be non-negative"); + return request; + } + } +} 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..07c8b242701c --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadResponse.java @@ -0,0 +1,86 @@ +/* + * 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 Builder newBuilder() { + return new AutoValue_ChunkUploadResponse.Builder().setComplete(false).setResponseBody(""); + } + + public static ChunkUploadResponse create(long committedOffset, boolean isComplete) { + return newBuilder().setCommittedOffset(committedOffset).setComplete(isComplete).build(); + } + + public static ChunkUploadResponse create( + long committedOffset, boolean isComplete, String responseBody) { + return newBuilder() + .setCommittedOffset(committedOffset) + .setComplete(isComplete) + .setResponseBody(responseBody) + .build(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setCommittedOffset(long committedOffset); + + public abstract Builder setComplete(boolean isComplete); + + public abstract Builder setResponseBody(String responseBody); + + 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 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(); } 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..feeccb0670a6 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadRequestTest.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 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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ChunkUploadRequestTest { + + private static final String UPLOAD_URL = "https://upload.googleapis.com/resumable/session/123"; + private static final ByteString PAYLOAD = ByteString.copyFromUtf8("chunk data"); + + @Test + void create_defaultIsFinalIsFalse() { + ChunkUploadRequest request = ChunkUploadRequest.create(UPLOAD_URL, PAYLOAD, 1024L); + + assertThat(request.isFinal()).isFalse(); + } + + @ParameterizedTest + @ValueSource(longs = {-1L, -100L, Long.MIN_VALUE}) + void builder_withNegativeOffset_throwsIllegalArgumentException(long negativeOffset) { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + ChunkUploadRequest.newBuilder() + .setUploadUrl(UPLOAD_URL) + .setPayload(PAYLOAD) + .setOffset(negativeOffset) + .build()); + + assertThat(exception).hasMessageThat().contains("offset must be non-negative"); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadResponseTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadResponseTest.java new file mode 100644 index 000000000000..93a8b288c2a2 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ChunkUploadResponseTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumable; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class ChunkUploadResponseTest { + + @Test + void create_defaultResponseBodyIsEmptyAndCompleteIsFalse() { + ChunkUploadResponse response = ChunkUploadResponse.create(1024L, false); + + assertThat(response.getCommittedOffset()).isEqualTo(1024L); + assertThat(response.isComplete()).isFalse(); + assertThat(response.getResponseBody()).isEmpty(); + } +}