Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support google.api.HttpBody in gRPC/JSON transcoding #5400

Merged
merged 10 commits into from
Jul 25, 2024

Conversation

Dogacel
Copy link
Contributor

@Dogacel Dogacel commented Jan 21, 2024

Motivation:

#5311

Trying to make this work.

Modifications:

  • Parse media type and content dynamically if method descriptor is a google.api.HttpBody.

Result:

Need to do some testing, I couldn't fully understand how to do this. The way I am doing it is a bit tricky bit I guess it should work

Comment on lines 601 to 613
content, responseFuture,
generateResponseBodyConverter(spec), mediaType);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here it is trickier, we should pass the media type dynamically before responding. It is going to involve some minor changes to the deframe logic. (Such as determining the content type dynamically after response is generated and falling back if response is not generated).

Comment on lines +384 to +432
message ArbitraryHttpWrappedRequest {
string request_id = 1;
google.api.HttpBody body = 2;
}

message ArbitraryHttpWrappedResponse {
string response_id = 1;
google.api.HttpBody body = 2;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am not 100% sure how transcoder should behave when http body is a sub field. There is also extensions field which I did not understand either.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's fine until we hear more feedback on this feature from early adoptors. It won't harm at all anyway if a user is not using this feature, so I'd be happy to merge it even if sub-field and extensions cases are unsupported in this PR. 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, let me come back to this PR. I think I have left some gaps therefore this PR was forgotten in the Draft stage.

Copy link
Contributor Author

@Dogacel Dogacel Jun 6, 2024

Choose a reason for hiding this comment

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

Do you know if there is anyone waiting for this feature to be released? That would be great to get some feedback.

Copy link
Member

Choose a reason for hiding this comment

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

None that I'm aware of, but I know the colleagues of @hyangtack use HTTP/JSON transcoding, so they might be interested. If we release this and mention it in our release note, someone might start tinkering with it though! 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have updated the code, PTAL at the test cases and LMK what other tests we can run. For now those two seem like fine to me.

@github-actions github-actions bot added the Stale label Apr 9, 2024

// Media type can only be nullif the request doesn't have a content type.
if (mediaType == null) {
throw new IllegalArgumentException("Unsupported content-type: " +
Copy link
Member

Choose a reason for hiding this comment

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

Maybe should we fall back to octet-stream?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

First time hearing about it, mozilla docs also point to the same direction, https://arc.net/l/quote/dteigvyk

Generic binary data (or binary data whose true type is unknown) is application/octet-stream.

I think it makes sense 👍


try {
body.put("content_type", request.contentUtf8());
body.put("data", request.content().array());
Copy link
Member

Choose a reason for hiding this comment

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

Out of curiosity, how is a byte array serialized into JSON? How do we deal with a non-UTF8 data?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let me write some tests, in protobuf spec it should be base64 array.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looking back to this, I have realized this is still WIP. I'll come back.

I'm still trying to comprehend how transcoding works internally 🙂.

Copy link
Member

Choose a reason for hiding this comment

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

Sure. Please take your time. I appreciate that you continue working on this 💟

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have tested it out and turns out it uses base64. So in that case whether it is UTF8 or not should not matter, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So rest of the code should be self-explanatory. We will see.

Comment on lines +384 to +432
message ArbitraryHttpWrappedRequest {
string request_id = 1;
google.api.HttpBody body = 2;
}

message ArbitraryHttpWrappedResponse {
string response_id = 1;
google.api.HttpBody body = 2;
}
Copy link
Member

Choose a reason for hiding this comment

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

I think it's fine until we hear more feedback on this feature from early adoptors. It won't harm at all anyway if a user is not using this feature, so I'd be happy to merge it even if sub-field and extensions cases are unsupported in this PR. 😄

@github-actions github-actions bot removed the Stale label Jun 8, 2024
@Dogacel Dogacel force-pushed the dogac/support-http-body-in-json-transcoding branch from 259a7b4 to f213f19 Compare June 8, 2024 05:25
@Dogacel Dogacel marked this pull request as ready for review June 9, 2024 02:42
Comment on lines 471 to 472
// Default behavior is to return JSON_UTF_8, the default content type for JSON transcoding.
return MediaType.JSON_UTF_8;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@trustin I think this makes more sense for transcoding perspective. By default responses are json? WDY think?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure about the others, but my preference is to just set the content type as-is transparently.

So..

  • if non-null, then set content-type transparently
  • if null, we could just not set the content-type. Alternatively, I'm fine with setting a default of text/plain which is the default in armeria.

From my understanding, content-type isn't really mandatory depending on whether the body HttpBody exists. Also, upstream seems to be doing something similar ref: https://github.com/envoyproxy/envoy/blob/94037231eea7150a20b4a07b8c981ae89d1a0a60/source/extensions/filters/http/grpc_json_transcoder/json_transcoder_filter.cc#L441

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What does setting transparently mean? Are you implying we shouldn't add any header at all if it is unset? Kinda makes sense.

Copy link
Contributor

Choose a reason for hiding this comment

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

What does setting transparently mean?

For a little more background, MediaType is not really a comprehensive list of all content-types out there.
We pretty much copy over guava's often-used list of content-types manually whenever the following test fails. (we also add custom media types when necessary)

public void wellDefinedUpstreamMediaTypes() {

Given that users using HttpBody will be sending over arbitrary data, I wasn't sure if it's worth validating the content-type.

e.g.

final JsonNode contentType = jsonNode.get("contentType");
if (contentType != null && contentType.isTextual()) {
    builder.set(HttpHeaderNames.CONTENT_TYPE, contentType.textValue());
} else {
    builder.remove(HttpHeaderNames.CONTENT_TYPE);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, it doesn't have to be MediaType. I.e. application/scim+json wasn't an option when I checked it last time.

Comment on lines 500 to 504
if (charset != null) {
httpBodyBytes = Base64.getDecoder().decode(httpBody);
} else {
httpBodyBytes = Base64.getDecoder().decode(httpBody);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Response is Base64 because protobuf representation of body is byte[] and jackson serializes it as a base64 string.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I assume performance is not our first concern here so it seems fine 🙂

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

Mostly looks good 👍 Left some minor comments

}

body.put("content_type", contentType.toString());
body.put("data", content.array()); // Base64 encoded binary data
Copy link
Contributor

Choose a reason for hiding this comment

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

Double checked that we want to use base64 👍
ref: https://protobuf.dev/programming-guides/proto3/#json

@@ -599,6 +675,25 @@ private HttpResponse serve0(ServiceRequestContext ctx, HttpRequest req,
return HttpResponse.of(responseFuture);
}

private HttpData convertToArbitraryHttpData(AggregatedHttpRequest request) throws IOException {
Copy link
Contributor

Choose a reason for hiding this comment

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

optional; naming and static

Suggested change
private HttpData convertToArbitraryHttpData(AggregatedHttpRequest request) throws IOException {
private static HttpData convertToHttpBody(AggregatedHttpRequest request) throws IOException {

Comment on lines 499 to 504
final byte[] httpBodyBytes;
if (charset != null) {
httpBodyBytes = Base64.getDecoder().decode(httpBody);
} else {
httpBodyBytes = Base64.getDecoder().decode(httpBody);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Question) Is there a point in checking the charset? I think it's fine to delegate to the user responsibility for setting the arbitrary data as well as setting the correct charset. (also, the if statement isn't doing anything)

Suggested change
final byte[] httpBodyBytes;
if (charset != null) {
httpBodyBytes = Base64.getDecoder().decode(httpBody);
} else {
httpBodyBytes = Base64.getDecoder().decode(httpBody);
}
final byte[] httpBodyBytes = Base64.getDecoder().decode(httpBody);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, I must have deleted something. let me check this.

Comment on lines 471 to 472
// Default behavior is to return JSON_UTF_8, the default content type for JSON transcoding.
return MediaType.JSON_UTF_8;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure about the others, but my preference is to just set the content type as-is transparently.

So..

  • if non-null, then set content-type transparently
  • if null, we could just not set the content-type. Alternatively, I'm fine with setting a default of text/plain which is the default in armeria.

From my understanding, content-type isn't really mandatory depending on whether the body HttpBody exists. Also, upstream seems to be doing something similar ref: https://github.com/envoyproxy/envoy/blob/94037231eea7150a20b4a07b8c981ae89d1a0a60/source/extensions/filters/http/grpc_json_transcoder/json_transcoder_filter.cc#L441

try (HttpData data = httpData) {
final byte[] array = data.array();
try {
final JsonNode jsonNode = mapper.readValue(array, JsonNode.class);
Copy link
Contributor

Choose a reason for hiding this comment

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

Question) Is it possible to refactor so that only one deserialization is done? We seem to be doing this at generateResponseBodyConverter as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is a race condition, response body converter first deserializes the JSON, finds the body parameter and converts the body to the inner body parameter. So we lose content-type data from the original body.

A way to solve this would be returning body + media_type bundled together by updating the generateResponseBodyConverter method to be generateResponseBodyAndMediaTypeConverter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am not entirely sure if it is worth the effort the abstract this out. I feel like readability of the code dropped quite a bit when I have done this. I can try introducing a method that takes HttpResponse and emits another HttpResponse instead of the two converters. This method can transform headers & body on the go.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have used AggregatedHttpResponse because it seems like the response was already aggregated right before we passed it to the converter. LMK if anything looks odd.

@jrhee17 jrhee17 self-requested a review June 14, 2024 11:09
@jrhee17
Copy link
Contributor

jrhee17 commented Jun 14, 2024

Oops, approved by mistake 😅

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

Changes look pretty much good to me 👍 Left some final comments 🙇

Comment on lines 471 to 472
// Default behavior is to return JSON_UTF_8, the default content type for JSON transcoding.
return MediaType.JSON_UTF_8;
Copy link
Contributor

Choose a reason for hiding this comment

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

What does setting transparently mean?

For a little more background, MediaType is not really a comprehensive list of all content-types out there.
We pretty much copy over guava's often-used list of content-types manually whenever the following test fails. (we also add custom media types when necessary)

public void wellDefinedUpstreamMediaTypes() {

Given that users using HttpBody will be sending over arbitrary data, I wasn't sure if it's worth validating the content-type.

e.g.

final JsonNode contentType = jsonNode.get("contentType");
if (contentType != null && contentType.isTextual()) {
    builder.set(HttpHeaderNames.CONTENT_TYPE, contentType.textValue());
} else {
    builder.remove(HttpHeaderNames.CONTENT_TYPE);
}

@Dogacel Dogacel requested a review from jrhee17 June 17, 2024 14:40
Copy link
Member

@minwoox minwoox left a comment

Choose a reason for hiding this comment

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

This looks great! Thanks! 👍 👍 👍

@minwoox minwoox added this to the 1.30.0 milestone Jun 19, 2024
Copy link
Member

@trustin trustin left a comment

Choose a reason for hiding this comment

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

Nice work, @Dogacel! 🙇

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

Looks good 👍 Left some final comments 🙇

if (responseBody == null) {
return null;
} else {
return httpData -> {
try (HttpData data = httpData) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Question) Can we revive the old behavior to close the httpData in case the data is pooled?

if (jsonNode == null) {
return httpResponse;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we also close the httpResponse.content() once we're sure we will be using google.api.httpBody? (Either via try blocks, or by using PooledObjects.close(httpResponse.content()))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I guess I got it, I am not entirely sure how that try(...) works internally or what pooled objects are TBH. Please LMK if it looks right.

Copy link
Contributor

Choose a reason for hiding this comment

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

I am not entirely sure how that try(...) works internally or what pooled objects are TBH.

Once a gRPC message is written, it is converted to bytes via MessageMarshaller

return responseFramer.writePayload(marshaller.serializeResponse(message));

Here the channel's ByteBufAllocator is used to allocate bytes so that no additional copies are necessary when writing over the wire.

public ByteBuf serializeResponse(O message) throws IOException {

The response is aggregated with the pooled option, meaning don't copy the bytes

grpcResponse.aggregate(AggregationOptions.usePooledObjects(ctx.alloc(), ctx.eventLoop()))

Hence, if we want to use the httpResponse as-is in the early return, we shouldn't close the httpResponse.content().

Can you remove the try block here and modify like the following?

e.g.

return httpResponse -> {
    final HttpData data = httpResponse.content();
    final JsonNode jsonNode = extractHttpBody(data);

    // Failed to parse the JSON body, return the original response.
    if (jsonNode == null) {
        return httpResponse;
    }
    PooledObjects.close(data);

    // The data field is base64 encoded.
    // https://protobuf.dev/programming-guides/proto3/#json

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, I guess I kinda understand. What happens if we don't close the pool? I suspect the memory has to be claimed by the garbage collector which is inefficient? Or maybe there is some reference to this byte buffer left somewhere internally thus it might lead to a memory leak? (Seems unlikely)

Asking just for learning purposes 👼

Copy link
Contributor

Choose a reason for hiding this comment

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

This is more netty territory, but my understanding is that since direct memory is not cheap to allocate/deallocate netty maintains a pool of java ByteBuffers (may be direct or heap based)

If we don't call release on the ByteBuf, the ByteBuf object itself will be garbage collected but the memory pool will continue to have a strong reference to the underlyingByteBuffer - hence a memory leak. The others may shed more light on this subject though.

@Dogacel Dogacel requested a review from jrhee17 June 28, 2024 04:10
Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

Only two nits 👍

if (jsonNode == null) {
return httpResponse;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I am not entirely sure how that try(...) works internally or what pooled objects are TBH.

Once a gRPC message is written, it is converted to bytes via MessageMarshaller

return responseFramer.writePayload(marshaller.serializeResponse(message));

Here the channel's ByteBufAllocator is used to allocate bytes so that no additional copies are necessary when writing over the wire.

public ByteBuf serializeResponse(O message) throws IOException {

The response is aggregated with the pooled option, meaning don't copy the bytes

grpcResponse.aggregate(AggregationOptions.usePooledObjects(ctx.alloc(), ctx.eventLoop()))

Hence, if we want to use the httpResponse as-is in the early return, we shouldn't close the httpResponse.content().

Can you remove the try block here and modify like the following?

e.g.

return httpResponse -> {
    final HttpData data = httpResponse.content();
    final JsonNode jsonNode = extractHttpBody(data);

    // Failed to parse the JSON body, return the original response.
    if (jsonNode == null) {
        return httpResponse;
    }
    PooledObjects.close(data);

    // The data field is base64 encoded.
    // https://protobuf.dev/programming-guides/proto3/#json

Copy link
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

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

Nice work, @Dogacel! 👍👍

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

👍 👍 👍

@ikhoon ikhoon merged commit c7b2a44 into line:main Jul 25, 2024
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support sending arbitrary content for gRPC transcoding
5 participants