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..32fdb809ae77
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java
@@ -0,0 +1,267 @@
+/*
+ * 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.HttpMethods;
+import com.google.api.core.AbstractApiFuture;
+import com.google.api.core.ApiFuture;
+import com.google.api.core.InternalApi;
+import com.google.api.gax.resumable.ResumableUploadClient;
+import com.google.api.gax.resumable.ResumableUploadSession;
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.ApiExceptionFactory;
+import com.google.api.gax.rpc.ClientContext;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.api.gax.rpc.UnaryCallable;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Implementation of {@link ResumableUploadClient} using HTTP/JSON transport.
+ *
+ *
Executes the low-level HTTP wire calls for managing resumable upload sessions.
+ *
+ * @param request type for starting an upload
+ * @param response type of the upload method
+ */
+@NullMarked
+@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 Map> START_UPLOAD_HEADERS =
+ ImmutableMap.of(
+ UPLOAD_PROTOCOL_HEADER, ImmutableList.of("resumable"),
+ UPLOAD_COMMAND_HEADER, ImmutableList.of("start"));
+
+ private final ApiMethodDescriptor startUploadDescriptor;
+ private final UnaryCallable startUploadCallable;
+
+ public static HttpJsonResumableUploadClient create(
+ ClientContext clientContext, ApiMethodDescriptor methodDescriptor) {
+ return new HttpJsonResumableUploadClient<>(clientContext, methodDescriptor);
+ }
+
+ private HttpJsonResumableUploadClient(
+ ClientContext clientContext, ApiMethodDescriptor methodDescriptor) {
+ Preconditions.checkNotNull(clientContext);
+ Preconditions.checkNotNull(methodDescriptor);
+
+ this.startUploadDescriptor =
+ ApiMethodDescriptor.newBuilder()
+ .setFullMethodName(methodDescriptor.getFullMethodName())
+ .setHttpMethod(HttpMethods.POST)
+ .setType(ApiMethodDescriptor.MethodType.UNARY)
+ .setRequestFormatter(methodDescriptor.getRequestFormatter())
+ .setResponseParser(ResumableUploadResponseParser.create())
+ .build();
+ this.startUploadCallable = createStartUploadCallable(clientContext);
+ }
+
+ @Override
+ public UnaryCallable startUploadCallable() {
+ return startUploadCallable;
+ }
+
+ private UnaryCallable createStartUploadCallable(
+ ClientContext clientContext) {
+ UnaryCallable rawCallable =
+ new UnaryCallable() {
+ @Override
+ public ApiFuture futureCall(
+ RequestT request, @Nullable ApiCallContext inputContext) {
+ Preconditions.checkNotNull(request);
+ HttpJsonCallContext context =
+ createCallContext(clientContext, inputContext, START_UPLOAD_HEADERS);
+
+ HttpJsonClientCall clientCall =
+ HttpJsonClientCalls.newCall(startUploadDescriptor, context);
+
+ HttpJsonCallFuture future =
+ new HttpJsonCallFuture<>(clientCall);
+ HttpJsonClientCalls.startUnaryCall(
+ clientCall, request, context, new StartUploadResponseListener(future));
+
+ return future;
+ }
+ };
+ return createClientCallable(rawCallable, clientContext);
+ }
+
+ private static HttpJsonCallContext createCallContext(
+ ClientContext clientContext,
+ @Nullable ApiCallContext inputContext,
+ Map> extraHeaders) {
+ return (HttpJsonCallContext)
+ HttpJsonCallContext.createDefault()
+ .nullToSelf(clientContext.getDefaultCallContext())
+ .merge(inputContext)
+ .withExtraHeaders(extraHeaders);
+ }
+
+ private static UnaryCallable createClientCallable(
+ UnaryCallable innerCallable, ClientContext clientContext) {
+ UnaryCallable callable =
+ new HttpJsonExceptionCallable<>(
+ innerCallable,
+ // Wire calls do not retry directly; retries are managed by ResumableUploadCallable.
+ Collections.emptySet());
+ return callable.withDefaultCallContext(clientContext.getDefaultCallContext());
+ }
+
+ /**
+ * An {@link ApiFuture} that cancels the underlying {@link HttpJsonClientCall} to prevent
+ * connection leaks.
+ */
+ private static class HttpJsonCallFuture extends AbstractApiFuture {
+
+ private final HttpJsonClientCall, ?> call;
+
+ HttpJsonCallFuture(HttpJsonClientCall, ?> call) {
+ this.call = call;
+ }
+
+ @Override
+ protected void interruptTask() {
+ call.cancel("Call was cancelled", null);
+ }
+
+ @Override
+ public boolean set(T value) {
+ return super.set(value);
+ }
+
+ @Override
+ public boolean setException(Throwable throwable) {
+ return super.setException(throwable);
+ }
+ }
+
+ /** A listener that parses HTTP response headers to produce a {@link ResumableUploadSession}. */
+ private static class StartUploadResponseListener extends HttpJsonClientCall.Listener {
+
+ private final HttpJsonCallFuture future;
+ private long chunkGranularity = 1L;
+ @Nullable private String uploadUrl;
+ @Nullable private Throwable headerParsingException;
+
+ StartUploadResponseListener(HttpJsonCallFuture future) {
+ this.future = future;
+ }
+
+ @Override
+ public void onHeaders(HttpJsonMetadata responseHeaders) {
+ Map headers = responseHeaders.getHeaders();
+
+ String url = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_URL_HEADER);
+ if (!Strings.isNullOrEmpty(url)) {
+ this.uploadUrl = url;
+ }
+
+ String granularityStr = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_GRANULARITY_HEADER);
+ if (!Strings.isNullOrEmpty(granularityStr)) {
+ try {
+ long parsed = Long.parseLong(granularityStr);
+ if (parsed <= 0) {
+ this.headerParsingException =
+ ApiExceptionFactory.createException(
+ "Start upload response contained non-positive chunk granularity header: "
+ + granularityStr,
+ /* cause= */ null,
+ HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
+ /* retryable= */ false);
+ } else {
+ this.chunkGranularity = parsed;
+ }
+ } catch (NumberFormatException e) {
+ this.headerParsingException =
+ ApiExceptionFactory.createException(
+ "Start upload response contained invalid chunk granularity header: "
+ + granularityStr,
+ e,
+ HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
+ /* retryable= */ false);
+ }
+ }
+ }
+
+ @Override
+ public void onMessage(@Nullable String message) {
+ // Response body is not needed for startUpload; session URL is in headers.
+ }
+
+ @Override
+ public void onClose(int statusCode, HttpJsonMetadata trailers) {
+ try {
+ if (statusCode >= 200 && statusCode < 300) {
+ if (headerParsingException != null) {
+ future.setException(headerParsingException);
+ return;
+ }
+ if (!Strings.isNullOrEmpty(uploadUrl)) {
+ future.set(
+ ResumableUploadSession.newBuilder()
+ .setUploadUrl(uploadUrl)
+ .setChunkGranularity(chunkGranularity)
+ .build());
+ } else {
+ future.setException(
+ ApiExceptionFactory.createException(
+ "Start upload response did not contain upload session URL header",
+ /* cause= */ null,
+ HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
+ /* retryable= */ false));
+ }
+ } else {
+ Throwable cause = trailers.getException();
+ future.setException(
+ cause != null
+ ? cause
+ : new HttpJsonStatusRuntimeException(
+ statusCode, "Failed to start upload with status code: " + statusCode, null));
+ }
+ } catch (Throwable t) {
+ future.setException(t);
+ }
+ }
+ }
+}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStatusRuntimeException.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStatusRuntimeException.java
index af357e0952eb..4c6234bc8a86 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStatusRuntimeException.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStatusRuntimeException.java
@@ -31,6 +31,7 @@
package com.google.api.gax.httpjson;
import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
/**
* HTTP status code in RuntimeException form, for propagating status code information via
@@ -42,7 +43,8 @@ public class HttpJsonStatusRuntimeException extends RuntimeException {
private final int statusCode;
- public HttpJsonStatusRuntimeException(int statusCode, String message, Throwable cause) {
+ public HttpJsonStatusRuntimeException(
+ int statusCode, @Nullable String message, @Nullable Throwable cause) {
super(message, cause);
this.statusCode = statusCode;
}
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..9971e054f8fa
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java
@@ -0,0 +1,338 @@
+/*
+ * 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.HttpMethods;
+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.core.InternalApi;
+import com.google.api.gax.resumable.ResumableUploadSession;
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.ClientContext;
+import com.google.api.gax.rpc.InternalException;
+import com.google.api.gax.rpc.NotFoundException;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.api.pathtemplate.PathTemplate;
+import com.google.common.base.Strings;
+import 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.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@InternalApi
+class HttpJsonResumableUploadClientTest {
+
+ private static final String TEST_UPLOAD_URL =
+ "https://test.googleapis.com/upload/session/test-session-id";
+
+ private static ExecutorService executorService;
+
+ @BeforeAll
+ static void setUp() {
+ executorService = Executors.newFixedThreadPool(2);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ executorService.shutdownNow();
+ }
+
+ @Nested
+ class StartUpload {
+
+ @Test
+ void startUpload_validHeaders_returnsSession() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(200);
+ httpResponse.addHeader("X-Goog-Upload-URL", TEST_UPLOAD_URL);
+ httpResponse.addHeader("X-Goog-Upload-Chunk-Granularity", "262144");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ TestRequest request = new TestRequest("upload/v1/resources");
+
+ ResumableUploadSession session = client.startUploadCallable().call(request);
+
+ assertThat(session.getUploadUrl()).isEqualTo(TEST_UPLOAD_URL);
+ assertThat(session.getChunkGranularity()).isEqualTo(262144L);
+ }
+
+ @Test
+ void startUpload_caseInsensitiveHeaders_returnsSession() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(200);
+ httpResponse.addHeader(
+ "x-goog-upload-url", "https://test.googleapis.com/upload/session/case-insensitive");
+ httpResponse.addHeader("x-goog-upload-chunk-granularity", "524288");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ TestRequest request = new TestRequest("upload/v1/resources");
+
+ ResumableUploadSession session = client.startUploadCallable().call(request);
+
+ assertThat(session.getUploadUrl())
+ .isEqualTo("https://test.googleapis.com/upload/session/case-insensitive");
+ assertThat(session.getChunkGranularity()).isEqualTo(524288L);
+ }
+
+ @Test
+ void startUpload_malformedChunkGranularityHeader_throwsException() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(200);
+ httpResponse.addHeader("X-Goog-Upload-URL", TEST_UPLOAD_URL);
+ httpResponse.addHeader("X-Goog-Upload-Chunk-Granularity", "not-a-number");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ TestRequest request = new TestRequest("upload/v1/resources");
+
+ ExecutionException exception =
+ assertThrows(
+ ExecutionException.class,
+ () -> client.startUploadCallable().futureCall(request).get());
+
+ assertThat(exception.getCause()).isInstanceOf(InternalException.class);
+ assertThat(exception.getCause().getCause()).isInstanceOf(NumberFormatException.class);
+ }
+
+ @Test
+ void startUpload_nonPositiveChunkGranularityHeader_throwsException() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(200);
+ httpResponse.addHeader("X-Goog-Upload-URL", TEST_UPLOAD_URL);
+ httpResponse.addHeader("X-Goog-Upload-Chunk-Granularity", "-256");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ TestRequest request = new TestRequest("upload/v1/resources");
+
+ ExecutionException exception =
+ assertThrows(
+ ExecutionException.class,
+ () -> client.startUploadCallable().futureCall(request).get());
+
+ assertThat(exception.getCause()).isInstanceOf(InternalException.class);
+ assertThat(exception.getCause())
+ .hasMessageThat()
+ .contains("Start upload response contained non-positive chunk granularity header: -256");
+ }
+
+ @Test
+ void startUpload_missingSessionUrlHeader_throwsException() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(200);
+ httpResponse.addHeader("X-Goog-Upload-Chunk-Granularity", "262144");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ TestRequest request = new TestRequest("upload/v1/resources");
+
+ ExecutionException exception =
+ assertThrows(
+ ExecutionException.class,
+ () -> client.startUploadCallable().futureCall(request).get());
+
+ assertThat(exception.getCause()).isInstanceOf(InternalException.class);
+ assertThat(exception.getCause())
+ .hasMessageThat()
+ .contains("Start upload response did not contain upload session URL header");
+ }
+
+ @Test
+ void startUpload_withPayloadAndQueryParams_sendsCorrectRequest() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ response.addHeader("X-Goog-Upload-URL", TEST_UPLOAD_URL);
+
+ CapturingHttpTransport transport = new CapturingHttpTransport(response);
+ HttpJsonResumableUploadClient client = createClient(transport);
+
+ Map> queryParams =
+ Collections.singletonMap("uploadType", Collections.singletonList("resumable"));
+ TestRequest request =
+ new TestRequest("upload/v1/resources", "{\"name\":\"my-resource.txt\"}", queryParams);
+
+ client.startUploadCallable().call(request);
+
+ assertThat(transport.capturedUrl)
+ .isEqualTo("https://test.googleapis.com/upload/v1/resources?uploadType=resumable");
+ assertThat(transport.capturedContent).isEqualTo("{\"name\":\"my-resource.txt\"}");
+ assertThat(transport.capturedHeaders.get("x-goog-upload-protocol"))
+ .containsExactly("resumable");
+ assertThat(transport.capturedHeaders.get("x-goog-upload-command")).containsExactly("start");
+ }
+
+ @Test
+ void startUpload_serverReturnsError_throwsApiException() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(404);
+ httpResponse.setContent("{\"error\":{\"message\":\"Resource not found\"}}");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ TestRequest request = new TestRequest("upload/v1/nonexistent");
+
+ ExecutionException exception =
+ assertThrows(
+ ExecutionException.class,
+ () -> client.startUploadCallable().futureCall(request).get());
+
+ assertThat(exception.getCause()).isInstanceOf(NotFoundException.class);
+ NotFoundException notFoundException = (NotFoundException) exception.getCause();
+ assertThat(notFoundException.getStatusCode().getCode()).isEqualTo(StatusCode.Code.NOT_FOUND);
+ }
+
+ @Test
+ void startUpload_withCustomExtraHeaders_preservesHeaders() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ response.addHeader("X-Goog-Upload-URL", TEST_UPLOAD_URL);
+
+ CapturingHttpTransport transport = new CapturingHttpTransport(response);
+ HttpJsonResumableUploadClient client = createClient(transport);
+
+ TestRequest request = new TestRequest("upload/v1/resources");
+ Map> customHeaders =
+ Collections.singletonMap("X-Custom-Header", Collections.singletonList("CustomValue"));
+
+ ApiCallContext callContext =
+ HttpJsonCallContext.createDefault().withExtraHeaders(customHeaders);
+
+ client.startUploadCallable().call(request, callContext);
+
+ assertThat(transport.capturedHeaders.get("x-custom-header")).containsExactly("CustomValue");
+ }
+ }
+
+ 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, TEST_METHOD_DESCRIPTOR);
+ }
+
+ private static HttpJsonResumableUploadClient createClient(
+ MockLowLevelHttpResponse response) {
+ return createClient(new MockHttpTransport.Builder().setLowLevelHttpResponse(response).build());
+ }
+
+ /** A mock transport that captures request URL, headers, and body for verification. */
+ private static class CapturingHttpTransport extends MockHttpTransport {
+ private final MockLowLevelHttpResponse response;
+ final Map> capturedHeaders = new HashMap<>();
+ @Nullable String capturedUrl;
+ @Nullable String capturedContent;
+
+ CapturingHttpTransport(MockLowLevelHttpResponse response) {
+ this.response = response;
+ }
+
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ this.capturedUrl = url;
+ return new MockLowLevelHttpRequest() {
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ capturedHeaders.putAll(getHeaders());
+ capturedContent = getContentAsString();
+ return response;
+ }
+ };
+ }
+ }
+
+ private static final ApiMethodDescriptor TEST_METHOD_DESCRIPTOR =
+ ApiMethodDescriptor.newBuilder()
+ .setFullMethodName("ResumableUpload/StartUpload")
+ .setHttpMethod(HttpMethods.POST)
+ .setType(ApiMethodDescriptor.MethodType.UNARY)
+ .setRequestFormatter(
+ new HttpRequestFormatter() {
+ @Override
+ public Map> getQueryParamNames(TestRequest request) {
+ return request.queryParams;
+ }
+
+ @Override
+ public String getRequestBody(TestRequest request) {
+ return Strings.nullToEmpty(request.jsonPayload);
+ }
+
+ @Override
+ public String getPath(TestRequest request) {
+ return request.path;
+ }
+
+ @Override
+ public PathTemplate getPathTemplate() {
+ return PathTemplate.create("{+path}");
+ }
+ })
+ .setResponseParser(ResumableUploadResponseParser.create())
+ .build();
+
+ private static class TestRequest {
+ final String path;
+ @Nullable final String jsonPayload;
+ final Map> queryParams;
+
+ TestRequest(String path) {
+ this(path, null, Collections.emptyMap());
+ }
+
+ TestRequest(String path, @Nullable String jsonPayload, Map> queryParams) {
+ this.path = path;
+ this.jsonPayload = jsonPayload;
+ this.queryParams = queryParams;
+ }
+ }
+}