From 7de9fde2fe5db55fdd4c3c84ea45397003b9931f Mon Sep 17 00:00:00 2001 From: whowes Date: Mon, 17 Aug 2026 16:21:05 +0000 Subject: [PATCH] feat(gax): allow non-JSON HttpContent and absolute request URLs in HttpRequestRunnable --- .../httpjson/HttpContentRequestFormatter.java | 56 +++++++++ .../api/gax/httpjson/HttpRequestRunnable.java | 51 ++++---- .../gax/httpjson/HttpRequestRunnableTest.java | 110 ++++++++++++++++++ 3 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpContentRequestFormatter.java diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpContentRequestFormatter.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpContentRequestFormatter.java new file mode 100644 index 000000000000..3af6f9ac7f16 --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpContentRequestFormatter.java @@ -0,0 +1,56 @@ +/* + * 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.HttpContent; +import org.jspecify.annotations.NullMarked; + +/** + * Formatter for requests that supply arbitrary {@link HttpContent} payloads (such as raw bytes or + * streams) rather than serialized JSON strings. + */ +@NullMarked +interface HttpContentRequestFormatter extends HttpRequestFormatter { + + /** Returns {@link HttpContent} representing the request body. */ + HttpContent getHttpContent(MessageFormatT apiMessage); + + /** + * Not supported. Formatters implementing this interface handle raw payloads that may not be + * serializable as JSON strings, providing them via {@link #getHttpContent(Object)} instead. + * + * @throws UnsupportedOperationException always + */ + @Override + default String getRequestBody(MessageFormatT apiMessage) { + throw new UnsupportedOperationException( + "HttpContentRequestFormatter uses getHttpContent() instead of getRequestBody()"); + } +} diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java index fbf44dfdae2a..2771b3dff9a6 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java @@ -29,21 +29,18 @@ */ package com.google.api.gax.httpjson; +import com.google.api.client.http.ByteArrayContent; import com.google.api.client.http.EmptyContent; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpContent; -import com.google.api.client.http.HttpMediaType; import com.google.api.client.http.HttpMethods; import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpTransport; -import com.google.api.client.http.json.JsonHttpContent; -import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonObjectParser; import com.google.api.client.json.gson.GsonFactory; -import com.google.api.client.util.GenericData; import com.google.api.gax.tracing.ApiTracer; import com.google.auth.Credentials; import com.google.auth.http.HttpCredentialsAdapter; @@ -154,8 +151,6 @@ public void run() { } HttpRequest createHttpRequest() throws IOException { - GenericData tokenRequest = new GenericData(); - HttpRequestFormatter requestFormatter = methodDescriptor.getRequestFormatter(); HttpRequestFactory requestFactory; @@ -166,24 +161,24 @@ HttpRequest createHttpRequest() throws IOException { requestFactory = httpTransport.createRequestFactory(); } - JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); // Create HTTP request body. - String requestBody = requestFormatter.getRequestBody(request); - HttpContent jsonHttpContent; - if (!Strings.isNullOrEmpty(requestBody)) { - jsonFactory.createJsonParser(requestBody).parse(tokenRequest); - jsonHttpContent = - new JsonHttpContent(jsonFactory, tokenRequest) - .setMediaType((new HttpMediaType("application/json; charset=utf-8"))); + HttpContent httpContent; + if (requestFormatter instanceof HttpContentRequestFormatter) { + httpContent = + ((HttpContentRequestFormatter) requestFormatter).getHttpContent(request); } else { - // Force underlying HTTP lib to set Content-Length header to avoid 411s. - // See EmptyContent.java. - jsonHttpContent = new EmptyContent(); + httpContent = createJsonHttpContent(requestFormatter); } // Populate URL path and query parameters. - String normalizedEndpoint = normalizeEndpoint(endpoint); - GenericUrl url = new GenericUrl(normalizedEndpoint + requestFormatter.getPath(request)); + String path = requestFormatter.getPath(request); + GenericUrl url; + if (path.startsWith("http://") || path.startsWith("https://")) { + url = new GenericUrl(path); + } else { + String normalizedEndpoint = normalizeEndpoint(endpoint); + url = new GenericUrl(normalizedEndpoint + path); + } Map> queryParams = requestFormatter.getQueryParamNames(request); for (Entry> queryParam : queryParams.entrySet()) { if (queryParam.getValue() != null) { @@ -196,20 +191,20 @@ HttpRequest createHttpRequest() throws IOException { tracer.requestUrlResolved(url.build()); } - HttpRequest httpRequest = buildRequest(requestFactory, url, jsonHttpContent); + HttpRequest httpRequest = buildRequest(requestFactory, url, httpContent); for (Map.Entry entry : headers.getHeaders().entrySet()) { HttpHeadersUtils.setHeader( httpRequest.getHeaders(), entry.getKey(), (String) entry.getValue()); } - httpRequest.setParser(new JsonObjectParser(jsonFactory)); + httpRequest.setParser(new JsonObjectParser(GsonFactory.getDefaultInstance())); return httpRequest; } private HttpRequest buildRequest( - HttpRequestFactory requestFactory, GenericUrl url, HttpContent jsonHttpContent) + HttpRequestFactory requestFactory, GenericUrl url, HttpContent httpContent) throws IOException { // A workaround to support PATCH request. This assumes support of "X-HTTP-Method-Override" // header on the server side, which GCP services usually do. @@ -235,7 +230,7 @@ private HttpRequest buildRequest( if (HttpMethods.PATCH.equals(actualHttpMethod)) { actualHttpMethod = HttpMethods.POST; } - HttpRequest httpRequest = requestFactory.buildRequest(actualHttpMethod, url, jsonHttpContent); + HttpRequest httpRequest = requestFactory.buildRequest(actualHttpMethod, url, httpContent); if (originalHttpMethod != null && !originalHttpMethod.equals(actualHttpMethod)) { HttpHeadersUtils.setHeader( httpRequest.getHeaders(), "X-HTTP-Method-Override", originalHttpMethod); @@ -284,6 +279,16 @@ private String normalizeEndpoint(String rawEndpoint) { return normalized; } + private HttpContent createJsonHttpContent(HttpRequestFormatter requestFormatter) { + String requestBody = requestFormatter.getRequestBody(request); + if (!Strings.isNullOrEmpty(requestBody)) { + return ByteArrayContent.fromString("application/json; charset=utf-8", requestBody); + } + // Force underlying HTTP lib to set Content-Length header to avoid 411s. + // See EmptyContent.java. + return new EmptyContent(); + } + @FunctionalInterface interface ResultListener { void setResult(RunnableResult result); diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java index 440b211ff9d1..947beab66fb0 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java @@ -29,14 +29,19 @@ */ package com.google.api.gax.httpjson; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import com.google.api.client.http.ByteArrayContent; import com.google.api.client.http.EmptyContent; +import com.google.api.client.http.HttpContent; import com.google.api.client.http.HttpRequest; import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.gax.tracing.ApiTracer; +import com.google.api.pathtemplate.PathTemplate; import com.google.common.truth.Truth; import com.google.longrunning.ListOperationsRequest; +import com.google.protobuf.ByteString; import com.google.protobuf.Empty; import com.google.protobuf.Field; import com.google.protobuf.util.JsonFormat; @@ -44,6 +49,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -326,4 +332,108 @@ void testUpdateRunnableTimeout_shouldUpdate() throws IOException { Truth.assertThat(httpRequest.getReadTimeout()).isEqualTo(30000L); Truth.assertThat(httpRequest.getConnectTimeout()).isEqualTo(30000L); } + + @Test + void testNonJsonHttpContent() throws IOException { + ByteString rawPayload = ByteString.copyFromUtf8("binary \0 raw \1 payload"); + HttpContentRequestFormatter binaryRequestFormatter = + new HttpContentRequestFormatter() { + @Override + public Map> getQueryParamNames(Field apiMessage) { + return Collections.emptyMap(); + } + + @Override + public HttpContent getHttpContent(Field apiMessage) { + return new ByteArrayContent("application/octet-stream", rawPayload.toByteArray()); + } + + @Override + public String getPath(Field apiMessage) { + return "/upload"; + } + + @Override + public PathTemplate getPathTemplate() { + return PathTemplate.create("{+path}"); + } + }; + + ApiMethodDescriptor methodDescriptor = + ApiMethodDescriptor.newBuilder() + .setFullMethodName("upload.binary") + .setHttpMethod("POST") + .setRequestFormatter(binaryRequestFormatter) + .setResponseParser(responseParser) + .build(); + + HttpRequestRunnable httpRequestRunnable = + new HttpRequestRunnable<>( + requestMessage, + methodDescriptor, + ENDPOINT, + HttpJsonCallOptions.newBuilder().build(), + new MockHttpTransport(), + HttpJsonMetadata.newBuilder().build(), + result -> {}); + + HttpRequest httpRequest = httpRequestRunnable.createHttpRequest(); + Truth.assertThat(httpRequest.getContent()).isInstanceOf(ByteArrayContent.class); + Truth.assertThat(httpRequest.getContent().getType()).isEqualTo("application/octet-stream"); + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + httpRequest.getContent().writeTo(out); + Truth.assertThat(out.toByteArray()).isEqualTo(rawPayload.toByteArray()); + } + assertThrows( + UnsupportedOperationException.class, + () -> binaryRequestFormatter.getRequestBody(requestMessage)); + } + + @Test + void testAbsoluteUrlSupport() throws IOException { + String absoluteUrl = "https://custom-upload-host.googleapis.com/upload/session/123?sid=abc"; + HttpRequestFormatter absoluteUrlFormatter = + new HttpRequestFormatter() { + @Override + public Map> getQueryParamNames(Field apiMessage) { + return Collections.emptyMap(); + } + + @Override + public String getRequestBody(Field apiMessage) { + return ""; + } + + @Override + public String getPath(Field apiMessage) { + return absoluteUrl; + } + + @Override + public PathTemplate getPathTemplate() { + return PathTemplate.create("{+path}"); + } + }; + + ApiMethodDescriptor methodDescriptor = + ApiMethodDescriptor.newBuilder() + .setFullMethodName("upload.absolute") + .setHttpMethod("POST") + .setRequestFormatter(absoluteUrlFormatter) + .setResponseParser(responseParser) + .build(); + + HttpRequestRunnable httpRequestRunnable = + new HttpRequestRunnable<>( + requestMessage, + methodDescriptor, + ENDPOINT, + HttpJsonCallOptions.newBuilder().build(), + new MockHttpTransport(), + HttpJsonMetadata.newBuilder().build(), + result -> {}); + + HttpRequest httpRequest = httpRequestRunnable.createHttpRequest(); + Truth.assertThat(httpRequest.getUrl().build()).isEqualTo(absoluteUrl); + } }