Skip to content

Commit ac4a49a

Browse files
authored
feat(gax): allow non-JSON HttpContent and absolute request URLs in HttpRequestRunnable (#14134)
GAX HTTP infrastructure currently assumes that - request content is always JSON - request URLs are always based on the client context associated with the service stub This PR relaxes those assumptions to allow non-JSON content and arbitrary URLs, which will be needed for resumable upload support.
1 parent 3df32ba commit ac4a49a

3 files changed

Lines changed: 254 additions & 19 deletions

File tree

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
*/
3030
package com.google.api.gax.httpjson;
3131

32+
import com.google.api.client.http.ByteArrayContent;
3233
import com.google.api.client.http.EmptyContent;
3334
import com.google.api.client.http.GenericUrl;
3435
import com.google.api.client.http.HttpContent;
@@ -154,8 +155,6 @@ public void run() {
154155
}
155156

156157
HttpRequest createHttpRequest() throws IOException {
157-
GenericData tokenRequest = new GenericData();
158-
159158
HttpRequestFormatter<RequestT> requestFormatter = methodDescriptor.getRequestFormatter();
160159

161160
HttpRequestFactory requestFactory;
@@ -166,24 +165,32 @@ HttpRequest createHttpRequest() throws IOException {
166165
requestFactory = httpTransport.createRequestFactory();
167166
}
168167

169-
JsonFactory jsonFactory = GsonFactory.getDefaultInstance();
170168
// Create HTTP request body.
171-
String requestBody = requestFormatter.getRequestBody(request);
172-
HttpContent jsonHttpContent;
173-
if (!Strings.isNullOrEmpty(requestBody)) {
174-
jsonFactory.createJsonParser(requestBody).parse(tokenRequest);
175-
jsonHttpContent =
176-
new JsonHttpContent(jsonFactory, tokenRequest)
177-
.setMediaType((new HttpMediaType("application/json; charset=utf-8")));
169+
HttpContent httpContent;
170+
if (requestFormatter instanceof ResumableUploadChunkRequestFormatter) {
171+
// Resumable upload requests include chunked binary content not representable as JSON
172+
byte[] binaryRequestBody =
173+
((ResumableUploadChunkRequestFormatter<RequestT>) requestFormatter)
174+
.getBinaryRequestBody(request);
175+
if (binaryRequestBody == null || binaryRequestBody.length == 0) {
176+
httpContent = new EmptyContent();
177+
} else {
178+
httpContent = new ByteArrayContent("application/octet-stream", binaryRequestBody);
179+
}
178180
} else {
179-
// Force underlying HTTP lib to set Content-Length header to avoid 411s.
180-
// See EmptyContent.java.
181-
jsonHttpContent = new EmptyContent();
181+
httpContent = createJsonHttpContent(requestFormatter);
182182
}
183183

184184
// Populate URL path and query parameters.
185-
String normalizedEndpoint = normalizeEndpoint(endpoint);
186-
GenericUrl url = new GenericUrl(normalizedEndpoint + requestFormatter.getPath(request));
185+
String path = requestFormatter.getPath(request);
186+
GenericUrl url;
187+
if (path.startsWith("http://") || path.startsWith("https://")) {
188+
// Absolute URL was provided (e.g. from a resumable upload start response)
189+
url = new GenericUrl(path);
190+
} else {
191+
String normalizedEndpoint = normalizeEndpoint(endpoint);
192+
url = new GenericUrl(normalizedEndpoint + path);
193+
}
187194
Map<String, List<String>> queryParams = requestFormatter.getQueryParamNames(request);
188195
for (Entry<String, List<String>> queryParam : queryParams.entrySet()) {
189196
if (queryParam.getValue() != null) {
@@ -196,20 +203,20 @@ HttpRequest createHttpRequest() throws IOException {
196203
tracer.requestUrlResolved(url.build());
197204
}
198205

199-
HttpRequest httpRequest = buildRequest(requestFactory, url, jsonHttpContent);
206+
HttpRequest httpRequest = buildRequest(requestFactory, url, httpContent);
200207

201208
for (Map.Entry<String, Object> entry : headers.getHeaders().entrySet()) {
202209
HttpHeadersUtils.setHeader(
203210
httpRequest.getHeaders(), entry.getKey(), (String) entry.getValue());
204211
}
205212

206-
httpRequest.setParser(new JsonObjectParser(jsonFactory));
213+
httpRequest.setParser(new JsonObjectParser(GsonFactory.getDefaultInstance()));
207214

208215
return httpRequest;
209216
}
210217

211218
private HttpRequest buildRequest(
212-
HttpRequestFactory requestFactory, GenericUrl url, HttpContent jsonHttpContent)
219+
HttpRequestFactory requestFactory, GenericUrl url, HttpContent httpContent)
213220
throws IOException {
214221
// A workaround to support PATCH request. This assumes support of "X-HTTP-Method-Override"
215222
// header on the server side, which GCP services usually do.
@@ -235,7 +242,7 @@ private HttpRequest buildRequest(
235242
if (HttpMethods.PATCH.equals(actualHttpMethod)) {
236243
actualHttpMethod = HttpMethods.POST;
237244
}
238-
HttpRequest httpRequest = requestFactory.buildRequest(actualHttpMethod, url, jsonHttpContent);
245+
HttpRequest httpRequest = requestFactory.buildRequest(actualHttpMethod, url, httpContent);
239246
if (originalHttpMethod != null && !originalHttpMethod.equals(actualHttpMethod)) {
240247
HttpHeadersUtils.setHeader(
241248
httpRequest.getHeaders(), "X-HTTP-Method-Override", originalHttpMethod);
@@ -284,6 +291,21 @@ private String normalizeEndpoint(String rawEndpoint) {
284291
return normalized;
285292
}
286293

294+
private HttpContent createJsonHttpContent(HttpRequestFormatter<RequestT> requestFormatter)
295+
throws IOException {
296+
String requestBody = requestFormatter.getRequestBody(request);
297+
if (!Strings.isNullOrEmpty(requestBody)) {
298+
GenericData tokenRequest = new GenericData();
299+
JsonFactory jsonFactory = GsonFactory.getDefaultInstance();
300+
jsonFactory.createJsonParser(requestBody).parse(tokenRequest);
301+
return new JsonHttpContent(jsonFactory, tokenRequest)
302+
.setMediaType((new HttpMediaType("application/json; charset=utf-8")));
303+
}
304+
// Force underlying HTTP lib to set Content-Length header to avoid 411s.
305+
// See EmptyContent.java.
306+
return new EmptyContent();
307+
}
308+
287309
@FunctionalInterface
288310
interface ResultListener {
289311
void setResult(RunnableResult result);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.httpjson;
31+
32+
import org.jspecify.annotations.NullMarked;
33+
34+
/**
35+
* Formatter for resumable upload chunk requests that supply binary payload data rather than
36+
* serialized JSON strings.
37+
*/
38+
@NullMarked
39+
interface ResumableUploadChunkRequestFormatter<MessageFormatT>
40+
extends HttpRequestFormatter<MessageFormatT> {
41+
42+
/** Returns the binary payload representing the request body. */
43+
byte[] getBinaryRequestBody(MessageFormatT apiMessage);
44+
45+
/**
46+
* Not supported. Formatters implementing this interface handle raw binary payloads, providing
47+
* them via {@link #getBinaryRequestBody(Object)} instead.
48+
*
49+
* @throws UnsupportedOperationException always
50+
*/
51+
@Override
52+
default String getRequestBody(MessageFormatT apiMessage) {
53+
throw new UnsupportedOperationException(
54+
"ResumableUploadChunkRequestFormatter uses getBinaryRequestBody() instead of"
55+
+ " getRequestBody()");
56+
}
57+
}

sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,15 @@
2929
*/
3030
package com.google.api.gax.httpjson;
3131

32+
import static org.junit.jupiter.api.Assertions.assertThrows;
3233
import static org.mockito.Mockito.mock;
3334

35+
import com.google.api.client.http.ByteArrayContent;
3436
import com.google.api.client.http.EmptyContent;
3537
import com.google.api.client.http.HttpRequest;
3638
import com.google.api.client.testing.http.MockHttpTransport;
3739
import com.google.api.gax.tracing.ApiTracer;
40+
import com.google.api.pathtemplate.PathTemplate;
3841
import com.google.common.truth.Truth;
3942
import com.google.longrunning.ListOperationsRequest;
4043
import com.google.protobuf.Empty;
@@ -44,6 +47,7 @@
4447
import java.io.IOException;
4548
import java.nio.charset.StandardCharsets;
4649
import java.util.Arrays;
50+
import java.util.Collections;
4751
import java.util.HashMap;
4852
import java.util.LinkedHashMap;
4953
import java.util.List;
@@ -326,4 +330,156 @@ void testUpdateRunnableTimeout_shouldUpdate() throws IOException {
326330
Truth.assertThat(httpRequest.getReadTimeout()).isEqualTo(30000L);
327331
Truth.assertThat(httpRequest.getConnectTimeout()).isEqualTo(30000L);
328332
}
333+
334+
@Test
335+
void testResumableUploadChunkRequestFormatter() throws IOException {
336+
byte[] rawPayload = "binary \0 raw \1 payload".getBytes(StandardCharsets.UTF_8);
337+
ResumableUploadChunkRequestFormatter<Field> binaryRequestFormatter =
338+
new ResumableUploadChunkRequestFormatter<Field>() {
339+
@Override
340+
public Map<String, List<String>> getQueryParamNames(Field apiMessage) {
341+
return Collections.emptyMap();
342+
}
343+
344+
@Override
345+
public byte[] getBinaryRequestBody(Field apiMessage) {
346+
return rawPayload;
347+
}
348+
349+
@Override
350+
public String getPath(Field apiMessage) {
351+
return "/upload";
352+
}
353+
354+
@Override
355+
public PathTemplate getPathTemplate() {
356+
return PathTemplate.create("{+path}");
357+
}
358+
};
359+
360+
ApiMethodDescriptor<Field, Empty> methodDescriptor =
361+
ApiMethodDescriptor.<Field, Empty>newBuilder()
362+
.setFullMethodName("upload.binary")
363+
.setHttpMethod("POST")
364+
.setRequestFormatter(binaryRequestFormatter)
365+
.setResponseParser(responseParser)
366+
.build();
367+
368+
HttpRequestRunnable<Field, Empty> httpRequestRunnable =
369+
new HttpRequestRunnable<>(
370+
requestMessage,
371+
methodDescriptor,
372+
ENDPOINT,
373+
HttpJsonCallOptions.newBuilder().build(),
374+
new MockHttpTransport(),
375+
HttpJsonMetadata.newBuilder().build(),
376+
result -> {});
377+
378+
HttpRequest httpRequest = httpRequestRunnable.createHttpRequest();
379+
Truth.assertThat(httpRequest.getContent()).isInstanceOf(ByteArrayContent.class);
380+
Truth.assertThat(httpRequest.getContent().getType()).isEqualTo("application/octet-stream");
381+
Truth.assertThat(httpRequest.getContent().getLength()).isEqualTo(rawPayload.length);
382+
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
383+
httpRequest.getContent().writeTo(out);
384+
Truth.assertThat(out.toByteArray()).isEqualTo(rawPayload);
385+
}
386+
assertThrows(
387+
UnsupportedOperationException.class,
388+
() -> binaryRequestFormatter.getRequestBody(requestMessage));
389+
}
390+
391+
@Test
392+
void testResumableUploadChunkRequestFormatter_emptyPayload() throws IOException {
393+
ResumableUploadChunkRequestFormatter<Field> emptyRequestFormatter =
394+
new ResumableUploadChunkRequestFormatter<Field>() {
395+
@Override
396+
public Map<String, List<String>> getQueryParamNames(Field apiMessage) {
397+
return Collections.emptyMap();
398+
}
399+
400+
@Override
401+
public byte[] getBinaryRequestBody(Field apiMessage) {
402+
return new byte[0];
403+
}
404+
405+
@Override
406+
public String getPath(Field apiMessage) {
407+
return "/upload";
408+
}
409+
410+
@Override
411+
public PathTemplate getPathTemplate() {
412+
return PathTemplate.create("{+path}");
413+
}
414+
};
415+
416+
ApiMethodDescriptor<Field, Empty> methodDescriptor =
417+
ApiMethodDescriptor.<Field, Empty>newBuilder()
418+
.setFullMethodName("upload.empty")
419+
.setHttpMethod("POST")
420+
.setRequestFormatter(emptyRequestFormatter)
421+
.setResponseParser(responseParser)
422+
.build();
423+
424+
HttpRequestRunnable<Field, Empty> httpRequestRunnable =
425+
new HttpRequestRunnable<>(
426+
requestMessage,
427+
methodDescriptor,
428+
ENDPOINT,
429+
HttpJsonCallOptions.newBuilder().build(),
430+
new MockHttpTransport(),
431+
HttpJsonMetadata.newBuilder().build(),
432+
result -> {});
433+
434+
HttpRequest httpRequest = httpRequestRunnable.createHttpRequest();
435+
Truth.assertThat(httpRequest.getContent()).isInstanceOf(EmptyContent.class);
436+
}
437+
438+
@Test
439+
void testAbsoluteUrlSupport() throws IOException {
440+
String absoluteUrl = "https://custom-upload-host.googleapis.com/upload/session/123?sid=abc";
441+
HttpRequestFormatter<Field> absoluteUrlFormatter =
442+
new HttpRequestFormatter<Field>() {
443+
@Override
444+
public Map<String, List<String>> getQueryParamNames(Field apiMessage) {
445+
return Collections.emptyMap();
446+
}
447+
448+
@Override
449+
public String getRequestBody(Field apiMessage) {
450+
return "";
451+
}
452+
453+
@Override
454+
public String getPath(Field apiMessage) {
455+
return absoluteUrl;
456+
}
457+
458+
@Override
459+
public PathTemplate getPathTemplate() {
460+
return PathTemplate.create("{+path}");
461+
}
462+
};
463+
464+
ApiMethodDescriptor<Field, Empty> methodDescriptor =
465+
ApiMethodDescriptor.<Field, Empty>newBuilder()
466+
.setFullMethodName("upload.absolute")
467+
.setHttpMethod("POST")
468+
.setRequestFormatter(absoluteUrlFormatter)
469+
.setResponseParser(responseParser)
470+
.build();
471+
472+
HttpRequestRunnable<Field, Empty> httpRequestRunnable =
473+
new HttpRequestRunnable<>(
474+
requestMessage,
475+
methodDescriptor,
476+
ENDPOINT,
477+
HttpJsonCallOptions.newBuilder().build(),
478+
new MockHttpTransport(),
479+
HttpJsonMetadata.newBuilder().build(),
480+
result -> {});
481+
482+
HttpRequest httpRequest = httpRequestRunnable.createHttpRequest();
483+
Truth.assertThat(httpRequest.getUrl().build()).isEqualTo(absoluteUrl);
484+
}
329485
}

0 commit comments

Comments
 (0)