From c8cf564c4baa5aafb95d40857dbd04587af1a5a1 Mon Sep 17 00:00:00 2001 From: whowes Date: Mon, 17 Aug 2026 21:15:14 +0000 Subject: [PATCH 1/2] feat(gax): add StringHttpResponseParser --- .../httpjson/StringHttpResponseParser.java | 80 ++++++++++ .../StringHttpResponseParserTest.java | 141 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StringHttpResponseParser.java create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/StringHttpResponseParserTest.java diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StringHttpResponseParser.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StringHttpResponseParser.java new file mode 100644 index 000000000000..47ed0cddda91 --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StringHttpResponseParser.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.httpjson; + +import com.google.common.io.CharStreams; +import com.google.protobuf.TypeRegistry; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import org.jspecify.annotations.NullMarked; + +/** An {@link HttpResponseParser} that reads the HTTP response body as a UTF-8 String. */ +@NullMarked +class StringHttpResponseParser implements HttpResponseParser { + + private static final StringHttpResponseParser INSTANCE = new StringHttpResponseParser(); + + static StringHttpResponseParser create() { + return INSTANCE; + } + + private StringHttpResponseParser() {} + + @Override + public String parse(InputStream httpContent) { + try (Reader reader = new InputStreamReader(httpContent, StandardCharsets.UTF_8)) { + return CharStreams.toString(reader); + } catch (IOException e) { + throw new RestSerializationException("Failed to read response body as string", e); + } + } + + @Override + public String parse(InputStream httpContent, TypeRegistry registry) { + return parse(httpContent); + } + + @Override + public String parse(Reader httpContent, TypeRegistry registry) { + try { + return CharStreams.toString(httpContent); + } catch (IOException e) { + throw new RestSerializationException("Failed to read response body as string", e); + } + } + + @Override + public String serialize(String response) { + return response; + } +} diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/StringHttpResponseParserTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/StringHttpResponseParserTest.java new file mode 100644 index 000000000000..dc48797f61c9 --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/StringHttpResponseParserTest.java @@ -0,0 +1,141 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.protobuf.TypeRegistry; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StringHttpResponseParserTest { + + private StringHttpResponseParser parser; + + @BeforeEach + void setUp() { + parser = StringHttpResponseParser.create(); + } + + @Test + void parse_inputStream_returnsString() { + String expected = "Hello, world! \u4e16\u754c"; + InputStream inputStream = new ByteArrayInputStream(expected.getBytes(StandardCharsets.UTF_8)); + + String result = parser.parse(inputStream); + + assertThat(result).isEqualTo(expected); + } + + @Test + void parse_inputStream_empty_returnsEmptyString() { + InputStream inputStream = new ByteArrayInputStream(new byte[0]); + + String result = parser.parse(inputStream); + + assertThat(result).isEmpty(); + } + + @Test + void parse_inputStream_withTypeRegistry_returnsString() { + String expected = "response content"; + InputStream inputStream = new ByteArrayInputStream(expected.getBytes(StandardCharsets.UTF_8)); + + String result = parser.parse(inputStream, TypeRegistry.getEmptyTypeRegistry()); + + assertThat(result).isEqualTo(expected); + } + + @Test + void parse_inputStream_ioException_throwsRestSerializationException() { + InputStream failingInputStream = + new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("Simulated read failure"); + } + }; + + RestSerializationException thrown = + assertThrows(RestSerializationException.class, () -> parser.parse(failingInputStream)); + assertThat(thrown).hasCauseThat().isInstanceOf(IOException.class); + } + + @Test + void parse_reader_returnsString() { + String expected = "Hello from Reader!"; + Reader reader = new StringReader(expected); + + String result = parser.parse(reader, TypeRegistry.getEmptyTypeRegistry()); + + assertThat(result).isEqualTo(expected); + } + + @Test + void parse_reader_empty_returnsEmptyString() { + Reader reader = new StringReader(""); + + String result = parser.parse(reader, TypeRegistry.getEmptyTypeRegistry()); + + assertThat(result).isEmpty(); + } + + @Test + void parse_reader_ioException_throwsRestSerializationException() { + Reader failingReader = + new Reader() { + @Override + public int read(char[] cbuf, int off, int len) throws IOException { + throw new IOException("Simulated reader failure"); + } + + @Override + public void close() throws IOException {} + }; + + RestSerializationException thrown = + assertThrows( + RestSerializationException.class, + () -> parser.parse(failingReader, TypeRegistry.getEmptyTypeRegistry())); + assertThat(thrown).hasCauseThat().isInstanceOf(IOException.class); + } + + @Test + void serialize_returnsInputString() { + assertThat(parser.serialize("hello")).isEqualTo("hello"); + } +} From 27993fe9e22dc85820c6954d34c325c29810622c Mon Sep 17 00:00:00 2001 From: whowes Date: Fri, 14 Aug 2026 20:07:34 +0000 Subject: [PATCH 2/2] feat(gax): add ResumableUploadClient startUpload and HTTP/JSON implementation --- .../HttpJsonResumableUploadClient.java | 210 +++++++++++++++ .../HttpJsonResumableUploadClientTest.java | 248 ++++++++++++++++++ .../gax/resumable/ResumableUploadClient.java | 41 +++ .../gax/resumable/ResumableUploadSession.java | 80 ++++++ .../api/gax/resumable/StartUploadRequest.java | 101 +++++++ .../resumable/ResumableUploadClientTest.java | 86 ++++++ 6 files changed, 766 insertions(+) create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadSession.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java create mode 100644 sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadClientTest.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 new file mode 100644 index 000000000000..b8619d09ff4a --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java @@ -0,0 +1,210 @@ +/* + * 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.HttpHeaders; +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.ResumableUploadClient; +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.ClientContext; +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 java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Implementation of {@link ResumableUploadClient} using HTTP/JSON transport. + * + *

Executes the low-level HTTP wire calls for managing resumable upload sessions. + */ +@InternalApi +public final class HttpJsonResumableUploadClient implements ResumableUploadClient { + + 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_URL_HEADER = "X-Goog-Upload-URL"; + private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity"; + + private static final ApiMethodDescriptor START_UPLOAD_DESCRIPTOR = + ApiMethodDescriptor.newBuilder() + .setFullMethodName("ResumableUpload/StartUpload") + .setHttpMethod(HttpMethods.POST) + .setType(ApiMethodDescriptor.MethodType.UNARY) + .setRequestFormatter( + new HttpRequestFormatter() { + @Override + public Map> getQueryParamNames(StartUploadRequest request) { + return request.getQueryParams(); + } + + @Override + public String getRequestBody(StartUploadRequest request) { + return request.getJsonPayload(); + } + + @Override + public String getPath(StartUploadRequest request) { + return request.getPath(); + } + + @Override + public PathTemplate getPathTemplate() { + return PathTemplate.create("{+path}"); + } + }) + .setResponseParser(StringHttpResponseParser.create()) + .build(); + + private final ClientContext clientContext; + + public static HttpJsonResumableUploadClient create(ClientContext clientContext) { + return new HttpJsonResumableUploadClient(clientContext); + } + + private HttpJsonResumableUploadClient(ClientContext clientContext) { + this.clientContext = Preconditions.checkNotNull(clientContext); + } + + @Override + public UnaryCallable startUploadCallable() { + return new UnaryCallable() { + @Override + public ApiFuture futureCall( + StartUploadRequest request, ApiCallContext inputContext) { + Preconditions.checkNotNull(request); + HttpJsonCallContext context = + HttpJsonCallContext.createDefault() + .nullToSelf(clientContext.getDefaultCallContext()) + .merge(inputContext); + + Map> extraHeaders = new HashMap<>(context.getExtraHeaders()); + extraHeaders.putIfAbsent(UPLOAD_PROTOCOL_HEADER, Collections.singletonList("resumable")); + extraHeaders.putIfAbsent(UPLOAD_COMMAND_HEADER, Collections.singletonList("start")); + context = (HttpJsonCallContext) context.withExtraHeaders(extraHeaders); + + HttpJsonClientCall clientCall = + HttpJsonClientCalls.newCall(START_UPLOAD_DESCRIPTOR, context); + + SettableApiFuture future = SettableApiFuture.create(); + clientCall.start( + new StartUploadResponseListener(future), + HttpJsonClientCalls.getMetadataWithTraceContext(context)); + + try { + clientCall.sendMessage(request); + clientCall.halfClose(); + clientCall.request(2); + } catch (Throwable sendError) { + try { + clientCall.cancel(null, sendError); + } catch (Throwable ignored) { + } + throw sendError; + } + + return future; + } + }; + } + + private static class StartUploadResponseListener extends HttpJsonClientCall.Listener { + + private final SettableApiFuture future; + @Nullable private String uploadUrl; + private long chunkGranularity = 1L; + + StartUploadResponseListener(SettableApiFuture future) { + this.future = future; + } + + @Override + public void onHeaders(HttpJsonMetadata responseHeaders) { + if (responseHeaders != null && responseHeaders.getHeaders() != null) { + HttpHeaders headers; + if (responseHeaders.getHeaders() instanceof HttpHeaders) { + headers = (HttpHeaders) responseHeaders.getHeaders(); + } else { + headers = new HttpHeaders(); + headers.putAll(responseHeaders.getHeaders()); + } + + String url = headers.getFirstHeaderStringValue(UPLOAD_URL_HEADER); + if (Strings.isNullOrEmpty(url)) { + url = headers.getLocation(); + } + if (!Strings.isNullOrEmpty(url)) { + this.uploadUrl = url; + } + + String granularityStr = headers.getFirstHeaderStringValue(UPLOAD_GRANULARITY_HEADER); + if (!Strings.isNullOrEmpty(granularityStr)) { + try { + this.chunkGranularity = Long.parseLong(granularityStr); + } catch (NumberFormatException ignored) { + this.chunkGranularity = 1L; + } + } + } + } + + @Override + public void onMessage(@Nullable String message) {} + + @Override + public void onClose(int statusCode, HttpJsonMetadata trailers) { + if (statusCode >= 200 && statusCode < 300) { + if (!Strings.isNullOrEmpty(uploadUrl)) { + future.set(ResumableUploadSession.create(uploadUrl, chunkGranularity)); + } else { + future.setException( + new HttpJsonStatusRuntimeException( + statusCode, + "Start upload response did not contain upload session URL header", + null)); + } + } else { + future.setException( + trailers != null && trailers.getException() != null + ? trailers.getException() + : new HttpJsonStatusRuntimeException(statusCode, "Failed to start upload", null)); + } + } + } +} 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 new file mode 100644 index 000000000000..ce80c9505a03 --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java @@ -0,0 +1,248 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +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.ResumableUploadSession; +import com.google.api.gax.resumable.StartUploadRequest; +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ClientContext; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class HttpJsonResumableUploadClientTest { + + private static ExecutorService executorService; + + @BeforeAll + static void setUp() { + executorService = Executors.newFixedThreadPool(2); + } + + @AfterAll + static void tearDown() { + executorService.shutdownNow(); + } + + private static HttpJsonResumableUploadClient createClient(HttpTransport transport) { + ManagedHttpJsonChannel channel = + ManagedHttpJsonChannel.newBuilder() + .setEndpoint("test.googleapis.com") + .setExecutor(executorService) + .setHttpTransport(transport) + .build(); + + ClientContext clientContext = + ClientContext.newBuilder() + .setTransportChannel(HttpJsonTransportChannel.create(channel)) + .setDefaultCallContext(HttpJsonCallContext.createDefault().withChannel(channel)) + .build(); + + return HttpJsonResumableUploadClient.create(clientContext); + } + + private static HttpJsonResumableUploadClient createClient(MockLowLevelHttpResponse response) { + return createClient(new MockHttpTransport.Builder().setLowLevelHttpResponse(response).build()); + } + + @Test + void startUpload_withUploadUrlHeader_success() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-URL", "https://test.googleapis.com/upload/session/abc"); + httpResponse.addHeader("X-Goog-Upload-Chunk-Granularity", "262144"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + StartUploadRequest request = StartUploadRequest.create("upload/v1/resources"); + + ResumableUploadSession session = client.startUploadCallable().call(request); + + assertThat(session.getUploadUrl()).isEqualTo("https://test.googleapis.com/upload/session/abc"); + assertThat(session.getChunkGranularity()).isEqualTo(262144L); + } + + @Test + void startUpload_withLocationHeaderFallback_success() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("Location", "https://test.googleapis.com/upload/session/xyz"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + StartUploadRequest request = StartUploadRequest.create("/upload/v1/resources"); + + ResumableUploadSession session = client.startUploadCallable().call(request); + + assertThat(session.getUploadUrl()).isEqualTo("https://test.googleapis.com/upload/session/xyz"); + assertThat(session.getChunkGranularity()).isEqualTo(1L); + } + + @Test + void startUpload_missingSessionUrlHeader_throwsException() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + StartUploadRequest request = StartUploadRequest.create("upload/v1/resources"); + + ExecutionException exception = + assertThrows( + ExecutionException.class, () -> client.startUploadCallable().futureCall(request).get()); + + assertThat(exception.getCause()).isInstanceOf(HttpJsonStatusRuntimeException.class); + assertThat(exception.getCause()) + .hasMessageThat() + .contains("Start upload response did not contain upload session URL header"); + } + + @Test + void startUpload_withJsonPayloadAndQueryParams_sendsCorrectRequest() throws Exception { + Map> capturedHeaders = new HashMap<>(); + String[] capturedUrl = new String[1]; + String[] capturedContent = 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() throws IOException { + capturedHeaders.putAll(getHeaders()); + capturedContent[0] = getContentAsString(); + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + response.addHeader( + "X-Goog-Upload-URL", "https://test.googleapis.com/upload/session/123"); + return response; + } + }; + } + }; + + HttpJsonResumableUploadClient client = createClient(httpTransport); + Map> queryParams = new HashMap<>(); + queryParams.put("uploadType", Collections.singletonList("resumable")); + queryParams.put("name", Collections.singletonList("my-resource.txt")); + + StartUploadRequest request = + StartUploadRequest.create( + "upload/v1/resources", "{\"contentType\":\"text/plain\"}", queryParams); + + ResumableUploadSession session = client.startUploadCallable().call(request); + + assertThat(session.getUploadUrl()).isEqualTo("https://test.googleapis.com/upload/session/123"); + assertThat(capturedUrl[0]).contains("https://test.googleapis.com/upload/v1/resources"); + assertThat(capturedUrl[0]).contains("uploadType=resumable"); + assertThat(capturedUrl[0]).contains("name=my-resource.txt"); + assertThat(capturedContent[0]).isEqualTo("{\"contentType\":\"text/plain\"}"); + assertThat(capturedHeaders).containsKey("x-goog-upload-protocol"); + assertThat(capturedHeaders.get("x-goog-upload-protocol")).contains("resumable"); + assertThat(capturedHeaders).containsKey("x-goog-upload-command"); + assertThat(capturedHeaders.get("x-goog-upload-command")).contains("start"); + } + + @Test + void startUpload_serverReturnsError_throwsApiException() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(404); + httpResponse.setContent("{\"error\":{\"message\":\"Resource not found\"}}"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + StartUploadRequest request = StartUploadRequest.create("upload/v1/nonexistent"); + + ExecutionException exception = + assertThrows( + ExecutionException.class, () -> client.startUploadCallable().futureCall(request).get()); + + assertThat(exception.getCause()).isInstanceOf(HttpResponseException.class); + HttpResponseException httpResponseException = (HttpResponseException) exception.getCause(); + assertThat(httpResponseException.getStatusCode()).isEqualTo(404); + } + + @Test + void startUpload_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-URL", "https://test.googleapis.com/upload/session/custom"); + return response; + } + }; + } + }; + + HttpJsonResumableUploadClient client = createClient(httpTransport); + StartUploadRequest request = StartUploadRequest.create("upload/v1/resources"); + + Map> customHeaders = new HashMap<>(); + customHeaders.put("X-Custom-Header", Collections.singletonList("CustomValue")); + + ApiCallContext callContext = + HttpJsonCallContext.createDefault().withExtraHeaders(customHeaders); + + ResumableUploadSession session = client.startUploadCallable().call(request, callContext); + + assertThat(session.getUploadUrl()) + .isEqualTo("https://test.googleapis.com/upload/session/custom"); + assertThat(capturedHeaders).containsKey("x-custom-header"); + assertThat(capturedHeaders.get("x-custom-header")).contains("CustomValue"); + assertThat(capturedHeaders).containsKey("x-goog-upload-protocol"); + assertThat(capturedHeaders.get("x-goog-upload-protocol")).contains("resumable"); + } +} 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 new file mode 100644 index 000000000000..aa96451ad333 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java @@ -0,0 +1,41 @@ +/* + * 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.api.gax.rpc.UnaryCallable; + +/** Client interface for executing low-level resumable upload operations. */ +@InternalApi +public interface ResumableUploadClient { + + /** Returns a {@link UnaryCallable} to initiate a resumable upload session. */ + UnaryCallable startUploadCallable(); +} 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 new file mode 100644 index 000000000000..cb66afaecd91 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadSession.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.InternalApi; +import com.google.auto.value.AutoValue; + +/** Represents the session metadata returned after starting a resumable upload. */ +@InternalApi +@AutoValue +public abstract class ResumableUploadSession { + + private static final long DEFAULT_CHUNK_GRANULARITY = 1L; + + /** Returns the server-provided URL to which data uploads are directed. */ + public abstract String getUploadUrl(); + + /** + * Returns the server-mandated chunk granularity in bytes. + * + *

When specified by the server (via {@code X-Goog-Upload-Chunk-Granularity}), intermediate + * upload chunks must have a size and offset that are an exact multiple of this value (the final + * chunk may be smaller). If not specified by the server, this defaults to 1 byte, indicating no + * alignment or granularity requirements apply. + * + * @return the chunk granularity in bytes + */ + public abstract long getChunkGranularity(); + + /** + * Creates a {@link ResumableUploadSession} with the specified upload URL and default chunk + * granularity. + * + * @param uploadUrl the upload session URL + * @return a new {@link ResumableUploadSession} instance + */ + public static ResumableUploadSession create(String uploadUrl) { + return create(uploadUrl, DEFAULT_CHUNK_GRANULARITY); + } + + /** + * Creates a {@link ResumableUploadSession} with the specified upload URL and chunk granularity. + * + * @param uploadUrl the upload session URL + * @param chunkGranularity the chunk granularity in bytes; if ≤ 0, 1 is used to indicate no + * alignment or granularity requirements apply. + * @return a new {@link ResumableUploadSession} instance + */ + public static ResumableUploadSession create(String uploadUrl, long chunkGranularity) { + return new AutoValue_ResumableUploadSession( + uploadUrl, chunkGranularity > 0 ? chunkGranularity : DEFAULT_CHUNK_GRANULARITY); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java new file mode 100644 index 000000000000..88601414ddaf --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/StartUploadRequest.java @@ -0,0 +1,101 @@ +/* + * 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.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** Request parameters for initiating a resumable upload session. */ +@InternalApi +@AutoValue +public abstract class StartUploadRequest { + + /** Returns the URL path to append to the service endpoint. */ + public abstract String getPath(); + + /** Returns the optional initial JSON request payload. */ + @Nullable + public abstract String getJsonPayload(); + + /** Returns the query parameters for the initiation request. */ + public abstract Map> getQueryParams(); + + public abstract Builder toBuilder(); + + public static Builder builder() { + return new AutoValue_StartUploadRequest.Builder().setQueryParams(Collections.emptyMap()); + } + + public static StartUploadRequest create(String path) { + return create(path, null, Collections.emptyMap()); + } + + public static StartUploadRequest create(String path, @Nullable String jsonPayload) { + return create(path, jsonPayload, Collections.emptyMap()); + } + + public static StartUploadRequest create( + String path, @Nullable String jsonPayload, Map> queryParams) { + ImmutableMap.Builder> queryParamsBuilder = ImmutableMap.builder(); + for (Map.Entry> entry : queryParams.entrySet()) { + queryParamsBuilder.put(entry.getKey(), ImmutableList.copyOf(entry.getValue())); + } + return builder() + .setPath(path) + .setJsonPayload(jsonPayload) + .setQueryParams(queryParamsBuilder.build()) + .build(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setPath(String path); + + public abstract Builder setJsonPayload(@Nullable String jsonPayload); + + public abstract Builder setQueryParams(Map> queryParams); + + abstract StartUploadRequest autoBuild(); + + public StartUploadRequest build() { + StartUploadRequest request = autoBuild(); + if (request.getPath().startsWith("/")) { + return request.toBuilder().setPath(request.getPath().substring(1)).autoBuild(); + } + return request; + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadClientTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadClientTest.java new file mode 100644 index 000000000000..f33773c4f76d --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadClientTest.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 static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ResumableUploadClientTest { + + private static final String UPLOAD_URL = "https://storage.googleapis.com/upload/session/12345"; + + @Test + void session_normalizesInvalidChunkGranularityToDefault() { + assertThat(ResumableUploadSession.create(UPLOAD_URL).getChunkGranularity()).isEqualTo(1L); + assertThat(ResumableUploadSession.create(UPLOAD_URL, 0).getChunkGranularity()).isEqualTo(1L); + assertThat(ResumableUploadSession.create(UPLOAD_URL, -100L).getChunkGranularity()) + .isEqualTo(1L); + assertThat(ResumableUploadSession.create(UPLOAD_URL, 256 * 1024L).getChunkGranularity()) + .isEqualTo(256 * 1024L); + } + + @Test + void startUploadRequest_guaranteesImmutabilityAndBuilderSupport() { + StartUploadRequest requestWithLeadingSlash = StartUploadRequest.create("/v1/upload"); + assertThat(requestWithLeadingSlash.getPath()).isEqualTo("v1/upload"); + assertThat(requestWithLeadingSlash.getJsonPayload()).isNull(); + + Map> mutableParams = new HashMap<>(); + List mutableList = new ArrayList<>(); + mutableList.add("value1"); + mutableParams.put("key1", mutableList); + + StartUploadRequest request = StartUploadRequest.create("/v1/upload", "{}", mutableParams); + assertThat(request.getPath()).isEqualTo("v1/upload"); + + // Mutate source map and list after construction + mutableParams.put("key2", Collections.singletonList("value2")); + mutableList.add("value2"); + + assertThat(request.getQueryParams()).hasSize(1); + assertThat(request.getQueryParams().get("key1")).containsExactly("value1"); + assertThrows( + UnsupportedOperationException.class, + () -> request.getQueryParams().put("key3", Collections.singletonList("value3"))); + + StartUploadRequest mutatedFromBuilder = + request.toBuilder().setPath("/v2/upload").setJsonPayload("{\"updated\":true}").build(); + assertThat(mutatedFromBuilder.getPath()).isEqualTo("v2/upload"); + assertThat(mutatedFromBuilder.getJsonPayload()).isEqualTo("{\"updated\":true}"); + assertThat(mutatedFromBuilder.getQueryParams()).hasSize(1); + } +}