Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<MessageFormatT> extends HttpRequestFormatter<MessageFormatT> {

Check warning on line 40 in sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpContentRequestFormatter.java

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaA7BCSiFCwuoEOGYLA5&open=AaA7BCSiFCwuoEOGYLA5&pullRequest=14134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The HttpContentRequestFormatter interface is currently package-private. Since HttpRequestFormatter is a public interface implemented by generated client stubs and other classes outside of the com.google.api.gax.httpjson package, this interface must be declared public so that it can be implemented by external classes (e.g., for resumable uploads in other packages).

Suggested change
interface HttpContentRequestFormatter<MessageFormatT> extends HttpRequestFormatter<MessageFormatT> {
public interface HttpContentRequestFormatter<MessageFormatT> extends HttpRequestFormatter<MessageFormatT> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentionally package private, as implementing classes are intended to live only in this package.


/** 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me think that getHttpContent should not belong to a request formatter. Because HttpContent is built from request body, exposing both and they don't work together would be confusing.

"HttpContentRequestFormatter uses getHttpContent() instead of getRequestBody()");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -154,8 +151,6 @@ public void run() {
}

HttpRequest createHttpRequest() throws IOException {
GenericData tokenRequest = new GenericData();

HttpRequestFormatter<RequestT> requestFormatter = methodDescriptor.getRequestFormatter();

HttpRequestFactory requestFactory;
Expand All @@ -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<RequestT>) 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://")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this for the upload URL that is returned from the start request?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the use case for this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Do you mind adding a comment for this special case?

url = new GenericUrl(path);
} else {
String normalizedEndpoint = normalizeEndpoint(endpoint);
url = new GenericUrl(normalizedEndpoint + path);
}
Map<String, List<String>> queryParams = requestFormatter.getQueryParamNames(request);
for (Entry<String, List<String>> queryParam : queryParams.entrySet()) {
if (queryParam.getValue() != null) {
Expand All @@ -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<String, Object> 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.
Expand All @@ -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);
Expand Down Expand Up @@ -284,6 +279,16 @@ private String normalizeEndpoint(String rawEndpoint) {
return normalized;
}

private HttpContent createJsonHttpContent(HttpRequestFormatter<RequestT> requestFormatter) {
String requestBody = requestFormatter.getRequestBody(request);
if (!Strings.isNullOrEmpty(requestBody)) {
return ByteArrayContent.fromString("application/json; charset=utf-8", requestBody);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does changing from JsonHttpContent to ByteArrayContent change any behaviors?

}
// 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,27 @@
*/
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;
import java.io.ByteArrayOutputStream;
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;
Expand Down Expand Up @@ -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<Field> binaryRequestFormatter =
new HttpContentRequestFormatter<Field>() {
@Override
public Map<String, List<String>> 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<Field, Empty> methodDescriptor =
ApiMethodDescriptor.<Field, Empty>newBuilder()
.setFullMethodName("upload.binary")
.setHttpMethod("POST")
.setRequestFormatter(binaryRequestFormatter)
.setResponseParser(responseParser)
.build();

HttpRequestRunnable<Field, Empty> 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<Field> absoluteUrlFormatter =
new HttpRequestFormatter<Field>() {
@Override
public Map<String, List<String>> 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<Field, Empty> methodDescriptor =
ApiMethodDescriptor.<Field, Empty>newBuilder()
.setFullMethodName("upload.absolute")
.setHttpMethod("POST")
.setRequestFormatter(absoluteUrlFormatter)
.setResponseParser(responseParser)
.build();

HttpRequestRunnable<Field, Empty> 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);
}
}
Loading