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; + } + } +}