-
Notifications
You must be signed in to change notification settings - Fork 1k
Support google.api.HttpBody in gRPC/JSON transcoding #5400
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
Changes from 7 commits
0db64be
c13de0f
f213f19
295c2a3
a8c5239
d549be7
9edd198
cab8d06
6478ab3
100a429
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |||||||
|
|
||||||||
| import java.io.IOException; | ||||||||
| import java.util.AbstractMap.SimpleImmutableEntry; | ||||||||
| import java.util.Base64; | ||||||||
| import java.util.HashMap; | ||||||||
| import java.util.Iterator; | ||||||||
| import java.util.List; | ||||||||
|
|
@@ -45,6 +46,7 @@ | |||||||
| import com.fasterxml.jackson.databind.node.ArrayNode; | ||||||||
| import com.fasterxml.jackson.databind.node.ObjectNode; | ||||||||
| import com.google.api.AnnotationsProto; | ||||||||
| import com.google.api.HttpBody; | ||||||||
| import com.google.api.HttpRule; | ||||||||
| import com.google.common.annotations.VisibleForTesting; | ||||||||
| import com.google.common.base.CaseFormat; | ||||||||
|
|
@@ -78,6 +80,7 @@ | |||||||
| import com.google.protobuf.Value; | ||||||||
|
|
||||||||
| import com.linecorp.armeria.common.AggregatedHttpRequest; | ||||||||
| import com.linecorp.armeria.common.AggregatedHttpResponse; | ||||||||
| import com.linecorp.armeria.common.HttpData; | ||||||||
| import com.linecorp.armeria.common.HttpMethod; | ||||||||
| import com.linecorp.armeria.common.HttpRequest; | ||||||||
|
|
@@ -87,6 +90,7 @@ | |||||||
| import com.linecorp.armeria.common.QueryParams; | ||||||||
| import com.linecorp.armeria.common.RequestHeaders; | ||||||||
| import com.linecorp.armeria.common.RequestHeadersBuilder; | ||||||||
| import com.linecorp.armeria.common.ResponseHeaders; | ||||||||
| import com.linecorp.armeria.common.annotation.Nullable; | ||||||||
| import com.linecorp.armeria.common.grpc.GrpcSerializationFormats; | ||||||||
| import com.linecorp.armeria.common.grpc.protocol.GrpcHeaderNames; | ||||||||
|
|
@@ -195,11 +199,11 @@ static GrpcService of(GrpcService delegate, HttpJsonTranscodingOptions httpJsonT | |||||||
| = toRouteAndPathVariables(additionalHttpRule); | ||||||||
| if (additionalRouteAndVariables != null) { | ||||||||
| specs.put(additionalRouteAndVariables.getKey(), | ||||||||
| new TranscodingSpec(order++, additionalHttpRule, methodDefinition, | ||||||||
| serviceDesc, methodDesc, originalFields, | ||||||||
| camelCaseFields, | ||||||||
| additionalRouteAndVariables.getValue(), | ||||||||
| responseBody)); | ||||||||
| new TranscodingSpec(order++, additionalHttpRule, methodDefinition, | ||||||||
| serviceDesc, methodDesc, originalFields, | ||||||||
| camelCaseFields, | ||||||||
| additionalRouteAndVariables.getValue(), | ||||||||
| responseBody)); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
@@ -435,7 +439,7 @@ private static String getResponseBody(List<FieldDescriptor> topLevelFields, | |||||||
| if (StringUtil.isNullOrEmpty(responseBody)) { | ||||||||
| return null; | ||||||||
| } | ||||||||
| for (FieldDescriptor fieldDescriptor: topLevelFields) { | ||||||||
| for (FieldDescriptor fieldDescriptor : topLevelFields) { | ||||||||
| if (fieldDescriptor.getName().equals(responseBody)) { | ||||||||
| return responseBody; | ||||||||
| } | ||||||||
|
|
@@ -444,42 +448,102 @@ private static String getResponseBody(List<FieldDescriptor> topLevelFields, | |||||||
| } | ||||||||
|
|
||||||||
| @Nullable | ||||||||
| private static Function<HttpData, HttpData> generateResponseBodyConverter(TranscodingSpec spec) { | ||||||||
| @Nullable final String responseBody = spec.responseBody; | ||||||||
| if (responseBody == null) { | ||||||||
| private static MediaType getMediaTypeFromHttpBody(JsonNode jsonNode) { | ||||||||
| final String contentType = jsonNode.get("contentType").asText(); | ||||||||
| try { | ||||||||
| return MediaType.parse(contentType); | ||||||||
| } catch (IllegalArgumentException e) { | ||||||||
| logger.warn("Invalid media type in http body content_type {}.", contentType); | ||||||||
| return null; | ||||||||
| } else { | ||||||||
| return httpData -> { | ||||||||
| try (HttpData data = httpData) { | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question) Can we revive the old behavior to close the |
||||||||
| final byte[] array = data.array(); | ||||||||
| try { | ||||||||
| final JsonNode jsonNode = mapper.readValue(array, JsonNode.class); | ||||||||
| // we try to convert lower snake case response body to camel case | ||||||||
| final String lowerCamelCaseResponseBody = | ||||||||
| CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, responseBody); | ||||||||
| final Iterator<Entry<String, JsonNode>> fields = jsonNode.fields(); | ||||||||
| while (fields.hasNext()) { | ||||||||
| final Entry<String, JsonNode> entry = fields.next(); | ||||||||
| final String fieldName = entry.getKey(); | ||||||||
| final JsonNode responseBodyJsonNode = entry.getValue(); | ||||||||
| // try to match field name and response body | ||||||||
| // 1. by default the marshaller would use lowerCamelCase in json field | ||||||||
| // 2. when the marshaller use original name in .proto file when serializing messages | ||||||||
| if (fieldName.equals(lowerCamelCaseResponseBody) || | ||||||||
| fieldName.equals(responseBody)) { | ||||||||
| final byte[] bytes = mapper.writeValueAsBytes(responseBodyJsonNode); | ||||||||
| return HttpData.wrap(bytes); | ||||||||
| } | ||||||||
| } | ||||||||
| return HttpData.ofUtf8("null"); | ||||||||
| } catch (IOException e) { | ||||||||
| logger.warn("Unexpected exception while extracting responseBody '{}' from {}", | ||||||||
| responseBody, data, e); | ||||||||
| return HttpData.wrap(array); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| @Nullable | ||||||||
| private static Function<AggregatedHttpResponse, AggregatedHttpResponse> generateResponseConverter( | ||||||||
| TranscodingSpec spec) { | ||||||||
| // Ignore the spec if the method is HttpBody. The response body is already in the correct format. | ||||||||
| if (HttpBody.getDescriptor().equals(spec.methodDescriptor.getOutputType())) { | ||||||||
| return httpResponse -> { | ||||||||
| final JsonNode jsonNode = extractHttpBody(httpResponse.content()); | ||||||||
|
|
||||||||
| // Failed to parse the JSON body, return the original response. | ||||||||
| if (jsonNode == null) { | ||||||||
| return httpResponse; | ||||||||
| } | ||||||||
|
|
||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we also close the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess I got it, I am not entirely sure how that
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Once a gRPC message is written, it is converted to bytes via armeria/grpc/src/main/java/com/linecorp/armeria/internal/server/grpc/AbstractServerCall.java Line 547 in ebb0888
Here the channel's armeria/grpc/src/main/java/com/linecorp/armeria/internal/common/grpc/GrpcMessageMarshaller.java Line 145 in ebb0888
The response is aggregated with the armeria/grpc/src/main/java/com/linecorp/armeria/server/grpc/AbstractUnframedGrpcService.java Line 172 in ebb0888
Hence, if we want to use the 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 👼
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 If we don't call |
||||||||
| // The data field is base64 encoded. | ||||||||
| // https://protobuf.dev/programming-guides/proto3/#json | ||||||||
| final String httpBody = jsonNode.get("data").asText(); | ||||||||
| final byte[] httpBodyBytes = Base64.getDecoder().decode(httpBody); | ||||||||
|
|
||||||||
| final ResponseHeaders newHeaders = httpResponse.headers().withMutations(builder -> { | ||||||||
| final MediaType mediaType = getMediaTypeFromHttpBody(jsonNode); | ||||||||
| if (mediaType != null) { | ||||||||
| builder.contentType(mediaType); | ||||||||
| } | ||||||||
| builder.contentLength(httpBodyBytes.length); | ||||||||
| }); | ||||||||
|
|
||||||||
| return AggregatedHttpResponse.of(newHeaders, HttpData.wrap(httpBodyBytes)); | ||||||||
| }; | ||||||||
| } | ||||||||
|
|
||||||||
| @Nullable | ||||||||
| final String responseBody = spec.responseBody; | ||||||||
| if (responseBody == null) { | ||||||||
| return null; | ||||||||
| } | ||||||||
|
|
||||||||
| return httpResponse -> { | ||||||||
| final HttpData convertedData = convertHttpDataForResponseBody(responseBody, httpResponse.content()); | ||||||||
| final ResponseHeaders newHeaders = httpResponse.headers().withMutations(builder -> { | ||||||||
| builder.contentLength(convertedData.length()); | ||||||||
| }); | ||||||||
| return AggregatedHttpResponse.of(newHeaders, convertedData); | ||||||||
| }; | ||||||||
| } | ||||||||
|
|
||||||||
| @Nullable | ||||||||
| private static JsonNode extractHttpBody(HttpData data) { | ||||||||
| final byte[] array = data.array(); | ||||||||
|
|
||||||||
| final JsonNode jsonNode; | ||||||||
|
Dogacel marked this conversation as resolved.
Outdated
|
||||||||
| try { | ||||||||
| return mapper.readValue(array, JsonNode.class); | ||||||||
| } catch (IOException e) { | ||||||||
| logger.warn("Unexpected exception while parsing HttpBody from {}", data, e); | ||||||||
| return null; | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| private static HttpData convertHttpDataForResponseBody(String responseBody, HttpData data) { | ||||||||
| final byte[] array = data.array(); | ||||||||
| try { | ||||||||
| final JsonNode jsonNode = mapper.readValue(array, JsonNode.class); | ||||||||
|
|
||||||||
| // we try to convert lower snake case response body to camel case | ||||||||
| final String lowerCamelCaseResponseBody = | ||||||||
| CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, responseBody); | ||||||||
| final Iterator<Entry<String, JsonNode>> fields = jsonNode.fields(); | ||||||||
| while (fields.hasNext()) { | ||||||||
| final Entry<String, JsonNode> entry = fields.next(); | ||||||||
| final String fieldName = entry.getKey(); | ||||||||
| final JsonNode responseBodyJsonNode = entry.getValue(); | ||||||||
| // try to match field name and response body | ||||||||
| // 1. by default the marshaller would use lowerCamelCase in json field | ||||||||
| // 2. when the marshaller use original name in .proto file when serializing messages | ||||||||
| if (fieldName.equals(lowerCamelCaseResponseBody) || | ||||||||
| fieldName.equals(responseBody)) { | ||||||||
| final byte[] bytes = mapper.writeValueAsBytes(responseBodyJsonNode); | ||||||||
| return HttpData.wrap(bytes); | ||||||||
| } | ||||||||
| } | ||||||||
| return HttpData.ofUtf8("null"); | ||||||||
| } catch (IOException e) { | ||||||||
| logger.warn("Unexpected exception while extracting responseBody '{}' from {}", | ||||||||
| responseBody, data, e); | ||||||||
| return HttpData.wrap(array); | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| private static final ObjectMapper mapper = JacksonUtil.newDefaultObjectMapper(); | ||||||||
|
|
@@ -582,10 +646,20 @@ private HttpResponse serve0(ServiceRequestContext ctx, HttpRequest req, | |||||||
| } else { | ||||||||
| try { | ||||||||
| ctx.setAttr(FramedGrpcService.RESOLVED_GRPC_METHOD, spec.method); | ||||||||
| // Set JSON media type (https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_json_transcoder_filter#sending-arbitrary-content) | ||||||||
| final HttpData requestContent; | ||||||||
|
|
||||||||
| // https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_json_transcoder_filter#sending-arbitrary-content | ||||||||
| if (HttpBody.getDescriptor().equals(spec.methodDescriptor.getInputType())) { | ||||||||
| // Convert the HTTP request to a JSON representation of HttpBody. | ||||||||
| requestContent = convertToHttpBody(clientRequest); | ||||||||
| } else { | ||||||||
| // Convert the HTTP request to gRPC JSON. | ||||||||
| requestContent = convertToJson(ctx, clientRequest, spec); | ||||||||
| } | ||||||||
|
|
||||||||
| frameAndServe(unwrap(), ctx, grpcHeaders.build(), | ||||||||
| convertToJson(ctx, clientRequest, spec), responseFuture, | ||||||||
| generateResponseBodyConverter(spec), MediaType.JSON_UTF_8); | ||||||||
| requestContent, responseFuture, | ||||||||
| generateResponseConverter(spec)); | ||||||||
| } catch (IllegalArgumentException iae) { | ||||||||
| responseFuture.completeExceptionally( | ||||||||
| HttpStatusException.of(HttpStatus.BAD_REQUEST, iae)); | ||||||||
|
|
@@ -599,6 +673,29 @@ private HttpResponse serve0(ServiceRequestContext ctx, HttpRequest req, | |||||||
| return HttpResponse.of(responseFuture); | ||||||||
| } | ||||||||
|
|
||||||||
| private static HttpData convertToHttpBody(AggregatedHttpRequest request) throws IOException { | ||||||||
| final ObjectNode body = mapper.createObjectNode(); | ||||||||
|
|
||||||||
| try (HttpData content = request.content()) { | ||||||||
| final MediaType contentType; | ||||||||
|
|
||||||||
| @Nullable | ||||||||
| final MediaType requestContentType = request.contentType(); | ||||||||
| if (requestContentType != null) { | ||||||||
| contentType = requestContentType; | ||||||||
| } else { | ||||||||
| contentType = MediaType.OCTET_STREAM; | ||||||||
| } | ||||||||
|
|
||||||||
| body.put("content_type", contentType.toString()); | ||||||||
| // Jackson converts byte array to base64 string. gRPC transcoding spec also returns base64 string. | ||||||||
| // https://protobuf.dev/programming-guides/proto3/#json | ||||||||
| body.put("data", content.array()); | ||||||||
|
|
||||||||
| return HttpData.wrap(mapper.writeValueAsBytes(body)); | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Converts the HTTP request to gRPC JSON with the {@link TranscodingSpec}. | ||||||||
| */ | ||||||||
|
|
@@ -625,7 +722,7 @@ private HttpData convertToJson(ServiceRequestContext ctx, | |||||||
| root = mapper.createObjectNode(); | ||||||||
| } else { | ||||||||
| throw new IllegalArgumentException("Unexpected JSON: " + | ||||||||
| body + ", (expected: ObjectNode or null)."); | ||||||||
| body + ", (expected: ObjectNode or null)."); | ||||||||
| } | ||||||||
| return setParametersAndWriteJson(root, ctx, spec); | ||||||||
| } | ||||||||
|
|
||||||||
Uh oh!
There was an error while loading. Please reload this page.