From c7b2a44e7f4df9d67b32303c62403f5f70b021ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fa=C3=A7=20Eldenk?= Date: Thu, 25 Jul 2024 04:34:35 -0500 Subject: [PATCH] Support google.api.HttpBody in gRPC/JSON transcoding (#5400) Motivation: #5311 Trying to make this work. Modifications: - Parse media type and content dynamically if method descriptor is a `google.api.HttpBody`. Result: - Closes #5311 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 --------- Co-authored-by: jrhee17 --- .../grpc/AbstractUnframedGrpcService.java | 34 ++-- .../grpc/HttpJsonTranscodingService.java | 175 +++++++++++++----- .../server/grpc/UnframedGrpcService.java | 12 +- .../it/grpc/HttpJsonTranscodingTest.java | 35 ++++ .../server/grpc/UnframedGrpcServiceTest.java | 14 +- .../test/proto/testing/grpc/transcoding.proto | 23 +++ 6 files changed, 232 insertions(+), 61 deletions(-) diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/AbstractUnframedGrpcService.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/AbstractUnframedGrpcService.java index 4970e7f658c..2ec1b632f11 100644 --- a/grpc/src/main/java/com/linecorp/armeria/server/grpc/AbstractUnframedGrpcService.java +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/AbstractUnframedGrpcService.java @@ -142,8 +142,7 @@ protected void frameAndServe( RequestHeaders grpcHeaders, HttpData content, CompletableFuture res, - @Nullable Function responseBodyConverter, - MediaType responseContentType) { + @Nullable Function responseConverter) { final HttpRequest grpcRequest; ctx.setAttr(IS_UNFRAMED_GRPC, true); try (ArmeriaMessageFramer framer = new ArmeriaMessageFramer( @@ -177,7 +176,7 @@ protected void frameAndServe( res.completeExceptionally(t); } else { deframeAndRespond(ctx, framedResponse, res, unframedGrpcErrorHandler, - responseBodyConverter, responseContentType); + responseConverter); } } return null; @@ -189,8 +188,8 @@ static void deframeAndRespond(ServiceRequestContext ctx, AggregatedHttpResponse grpcResponse, CompletableFuture res, UnframedGrpcErrorHandler unframedGrpcErrorHandler, - @Nullable Function responseBodyConverter, - MediaType responseContentType) { + @Nullable + Function responseConverter) { final HttpHeaders trailers = !grpcResponse.trailers().isEmpty() ? grpcResponse.trailers() : grpcResponse.headers(); final String grpcStatusCode = trailers.get(GrpcHeaderNames.GRPC_STATUS); @@ -226,19 +225,19 @@ static void deframeAndRespond(ServiceRequestContext ctx, final ResponseHeadersBuilder unframedHeaders = grpcResponse.headers().toBuilder(); unframedHeaders.set(GrpcHeaderNames.GRPC_STATUS, grpcStatusCode); // grpcStatusCode is 0 which is OK. - unframedHeaders.contentType(responseContentType); final ArmeriaMessageDeframer deframer = new ArmeriaMessageDeframer( // Max outbound message size is handled by the GrpcService, so we don't need to set it here. Integer.MAX_VALUE); + final Subscriber subscriber = singleSubscriber( + unframedHeaders, res, responseConverter); grpcResponse.toHttpResponse().decode(deframer, ctx.alloc()) - .subscribe(singleSubscriber(unframedHeaders, res, responseBodyConverter), ctx.eventLoop(), - SubscriptionOption.WITH_POOLED_OBJECTS); + .subscribe(subscriber, ctx.eventLoop(), SubscriptionOption.WITH_POOLED_OBJECTS); } static Subscriber singleSubscriber( ResponseHeadersBuilder unframedHeaders, CompletableFuture res, - @Nullable Function responseBodyConverter) { + @Nullable Function responseConverter) { return new Subscriber() { @Override @@ -249,12 +248,19 @@ public void onSubscribe(Subscription subscription) { @Override public void onNext(DeframedMessage message) { // We know that we don't support compression, so this is always a ByteBuf. - HttpData unframedContent = HttpData.wrap(message.buf()); - if (responseBodyConverter != null) { - unframedContent = responseBodyConverter.apply(unframedContent); + final HttpData unframedContent = HttpData.wrap(message.buf()); + unframedHeaders.contentType(MediaType.JSON_UTF_8); + + final AggregatedHttpResponse existingResponse = AggregatedHttpResponse.of( + unframedHeaders.build(), + unframedContent); + + if (responseConverter != null) { + final AggregatedHttpResponse convertedResponse = responseConverter.apply(existingResponse); + res.complete(convertedResponse.toHttpResponse()); + } else { + res.complete(existingResponse.toHttpResponse()); } - unframedHeaders.contentLength(unframedContent.length()); - res.complete(HttpResponse.of(unframedHeaders.build(), unframedContent)); } @Override diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java index 78f804bf00c..4f245d8b8f5 100644 --- a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java @@ -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,7 +80,9 @@ 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.HttpHeaderNames; import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; @@ -87,6 +91,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; @@ -107,6 +112,7 @@ import com.linecorp.armeria.server.grpc.HttpJsonTranscodingPathParser.VariablePathSegment; import com.linecorp.armeria.server.grpc.HttpJsonTranscodingPathParser.VerbPathSegment; import com.linecorp.armeria.server.grpc.HttpJsonTranscodingService.PathVariable.ValueDefinition.Type; +import com.linecorp.armeria.unsafe.PooledObjects; import io.grpc.MethodDescriptor.MethodType; import io.grpc.ServerMethodDefinition; @@ -195,11 +201,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 +441,7 @@ private static String getResponseBody(List topLevelFields, if (StringUtil.isNullOrEmpty(responseBody)) { return null; } - for (FieldDescriptor fieldDescriptor: topLevelFields) { + for (FieldDescriptor fieldDescriptor : topLevelFields) { if (fieldDescriptor.getName().equals(responseBody)) { return responseBody; } @@ -444,41 +450,93 @@ private static String getResponseBody(List topLevelFields, } @Nullable - private static Function generateResponseBodyConverter(TranscodingSpec spec) { - @Nullable final String responseBody = spec.responseBody; + private static Function 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 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 + final String httpBody = jsonNode.get("data").asText(); + final byte[] httpBodyBytes = Base64.getDecoder().decode(httpBody); + + final ResponseHeaders newHeaders = httpResponse.headers().withMutations(builder -> { + final JsonNode contentType = jsonNode.get("contentType"); + + if (contentType != null && contentType.isTextual()) { + builder.set(HttpHeaderNames.CONTENT_TYPE, contentType.textValue()); + } else { + builder.remove(HttpHeaderNames.CONTENT_TYPE); + } + }); + + return AggregatedHttpResponse.of(newHeaders, HttpData.wrap(httpBodyBytes)); + }; + } + + @Nullable + final String responseBody = spec.responseBody; if (responseBody == null) { return null; - } else { - return httpData -> { - try (HttpData data = httpData) { - 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> fields = jsonNode.fields(); - while (fields.hasNext()) { - final Entry 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); - } + } + + return httpResponse -> { + try (HttpData data = httpResponse.content()) { + final HttpData convertedData = convertHttpDataForResponseBody(responseBody, data); + return AggregatedHttpResponse.of(httpResponse.headers(), convertedData); + } + }; + } + + @Nullable + private static JsonNode extractHttpBody(HttpData data) { + final byte[] array = data.array(); + + 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> fields = jsonNode.fields(); + while (fields.hasNext()) { + final Entry 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); } } @@ -582,10 +640,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 +667,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 +716,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); } diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/UnframedGrpcService.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/UnframedGrpcService.java index d945e368df9..59b5ae4dad8 100644 --- a/grpc/src/main/java/com/linecorp/armeria/server/grpc/UnframedGrpcService.java +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/UnframedGrpcService.java @@ -20,7 +20,9 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.AggregationOptions; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; @@ -28,6 +30,7 @@ import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.RequestHeaders; import com.linecorp.armeria.common.RequestHeadersBuilder; +import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.grpc.GrpcSerializationFormats; import com.linecorp.armeria.common.grpc.protocol.GrpcHeaderNames; @@ -153,8 +156,15 @@ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exc if (t != null) { responseFuture.completeExceptionally(t); } else { + // Add the content type to the response headers. + final Function responseConverter = + response -> { + final ResponseHeaders headers = response.headers().withMutations( + builder -> builder.contentType(contentType)); + return AggregatedHttpResponse.of(headers, response.content()); + }; frameAndServe(unwrap(), ctx, grpcHeaders.build(), clientRequest.content(), - responseFuture, null, contentType); + responseFuture, responseConverter); } } return null; diff --git a/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java b/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java index eb93aef39b0..42889a6927e 100644 --- a/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java +++ b/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java @@ -45,6 +45,7 @@ import com.fasterxml.jackson.core.TreeNode; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.HttpBody; import com.google.common.collect.ImmutableList; import com.google.common.collect.Streams; @@ -76,6 +77,8 @@ import io.grpc.stub.StreamObserver; import testing.grpc.HttpJsonTranscodingTestServiceGrpc.HttpJsonTranscodingTestServiceBlockingStub; import testing.grpc.HttpJsonTranscodingTestServiceGrpc.HttpJsonTranscodingTestServiceImplBase; +import testing.grpc.Transcoding.ArbitraryHttpWrappedRequest; +import testing.grpc.Transcoding.ArbitraryHttpWrappedResponse; import testing.grpc.Transcoding.EchoAnyRequest; import testing.grpc.Transcoding.EchoAnyResponse; import testing.grpc.Transcoding.EchoFieldMaskRequest; @@ -333,6 +336,25 @@ public void echoNestedMessageField(EchoNestedMessageRequest request, .onNext(EchoNestedMessageResponse.newBuilder().setNested(request.getNested()).build()); responseObserver.onCompleted(); } + + @Override + public void arbitraryHttp(HttpBody request, StreamObserver responseObserver) { + final HttpBody.Builder builder = HttpBody.newBuilder(); + builder.setContentType(request.getContentType()) + .setData(request.getData()); + responseObserver.onNext(builder.build()); + responseObserver.onCompleted(); + } + + @Override + public void arbitraryHttpWrapped(ArbitraryHttpWrappedRequest request, + StreamObserver responseObserver) { + final ArbitraryHttpWrappedResponse.Builder builder = ArbitraryHttpWrappedResponse.newBuilder(); + builder.setResponseId(request.getRequestId() + "-response"); + builder.setBody(request.getBody()); + responseObserver.onNext(builder.build()); + responseObserver.onCompleted(); + } } @RegisterExtension @@ -1085,6 +1107,19 @@ public static JsonNode findMethod(JsonNode methods, String name) { .findFirst().get(); } + @Test + void shouldAcceptArbitraryHttpUsingHttpBody() { + final String content = "Arbitrary HTTP body"; + final RequestHeaders headers = RequestHeaders.builder() + .method(HttpMethod.POST).path("/v1/arbitrary") + .contentType(MediaType.HTML_UTF_8).build(); + final AggregatedHttpResponse response = + webClient.execute(headers, content.getBytes()).aggregate().join(); + + assertThat(response.contentType()).isEqualTo(MediaType.HTML_UTF_8); + assertThat(response.contentUtf8()).isEqualTo(content); + } + public static List pathMapping(JsonNode method) { return Streams.stream(method.get("endpoints")).map(node -> node.get("pathMapping").asText()) .collect(toImmutableList()); diff --git a/grpc/src/test/java/com/linecorp/armeria/server/grpc/UnframedGrpcServiceTest.java b/grpc/src/test/java/com/linecorp/armeria/server/grpc/UnframedGrpcServiceTest.java index e6bf05beb6d..af4957bdffd 100644 --- a/grpc/src/test/java/com/linecorp/armeria/server/grpc/UnframedGrpcServiceTest.java +++ b/grpc/src/test/java/com/linecorp/armeria/server/grpc/UnframedGrpcServiceTest.java @@ -23,6 +23,7 @@ import static org.mockito.Mockito.spy; import java.util.concurrent.CompletableFuture; +import java.util.function.Function; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -116,6 +117,11 @@ public void emptyCall(Empty request, StreamObserver responseObserver) { .startsWith("grpc-code: CANCELLED, Completed without a response"); } + private static Function contentType(MediaType type) { + return response -> AggregatedHttpResponse.of(response.headers().toBuilder().contentType(type).build(), + response.content()); + } + @Test void shouldClosePooledObjectsForNonOK() { final CompletableFuture res = new CompletableFuture<>(); @@ -127,7 +133,7 @@ void shouldClosePooledObjectsForNonOK() { final AggregatedHttpResponse framedResponse = AggregatedHttpResponse.of(responseHeaders, HttpData.wrap(byteBuf)); UnframedGrpcService.deframeAndRespond(ctx, framedResponse, res, UnframedGrpcErrorHandler.of(), - null, MediaType.PROTOBUF); + contentType(MediaType.PROTOBUF)); assertThat(byteBuf.refCnt()).isZero(); } @@ -141,7 +147,7 @@ void shouldClosePooledObjectsForMissingMediaType() { final AggregatedHttpResponse framedResponse = AggregatedHttpResponse .of(responseHeaders, HttpData.wrap(byteBuf)); AbstractUnframedGrpcService.deframeAndRespond(ctx, framedResponse, res, UnframedGrpcErrorHandler.of(), - null, MediaType.PROTOBUF); + contentType(MediaType.PROTOBUF)); assertThat(byteBuf.refCnt()).isZero(); } @@ -155,7 +161,7 @@ void shouldClosePooledObjectsForMissingGrpcStatus() { final AggregatedHttpResponse framedResponse = AggregatedHttpResponse.of(responseHeaders, HttpData.wrap(byteBuf)); AbstractUnframedGrpcService.deframeAndRespond(ctx, framedResponse, res, UnframedGrpcErrorHandler.of(), - null, MediaType.PROTOBUF); + contentType(MediaType.PROTOBUF)); assertThat(byteBuf.refCnt()).isZero(); } @@ -170,7 +176,7 @@ void succeedWithAllRequiredHeaders() throws Exception { final AggregatedHttpResponse framedResponse = AggregatedHttpResponse .of(responseHeaders, HttpData.wrap(byteBuf)); AbstractUnframedGrpcService.deframeAndRespond(ctx, framedResponse, res, UnframedGrpcErrorHandler.of(), - null, MediaType.PROTOBUF); + contentType(MediaType.PROTOBUF)); assertThat(HttpResponse.of(res).aggregate().get().status()).isEqualTo(HttpStatus.OK); } diff --git a/grpc/src/test/proto/testing/grpc/transcoding.proto b/grpc/src/test/proto/testing/grpc/transcoding.proto index 1e7c832bd51..4acc094e55d 100644 --- a/grpc/src/test/proto/testing/grpc/transcoding.proto +++ b/grpc/src/test/proto/testing/grpc/transcoding.proto @@ -27,6 +27,7 @@ import "google/protobuf/wrappers.proto"; import "google/protobuf/struct.proto"; import "google/protobuf/any.proto"; import "google/protobuf/field_mask.proto"; +import "google/api/httpbody.proto"; service HttpJsonTranscodingTestService { rpc GetMessageV1(GetMessageRequestV1) returns (Message) { @@ -224,6 +225,18 @@ service HttpJsonTranscodingTestService { } }; } + + rpc ArbitraryHttp(google.api.HttpBody) returns (google.api.HttpBody) { + option (google.api.http) = { + post: "/v1/arbitrary" + }; + } + + rpc ArbitraryHttpWrapped(ArbitraryHttpWrappedRequest) returns (ArbitraryHttpWrappedResponse) { + option (google.api.http) = { + post: "/v1/arbitrary_wrapped" + }; + } } message GetMessageRequestV1 { @@ -407,3 +420,13 @@ message EchoNestedMessageRequest { message EchoNestedMessageResponse { TopLevelMessage.NestedMessage nested = 1; } + +message ArbitraryHttpWrappedRequest { + string request_id = 1; + google.api.HttpBody body = 2; +} + +message ArbitraryHttpWrappedResponse { + string response_id = 1; + google.api.HttpBody body = 2; +}