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/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/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-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");
+ }
+}
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);
+ }
+}