diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/UnaryServerCallHandler.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/UnaryServerCallHandler.cs index 5313beb0a0c9..b36ba05aa102 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/UnaryServerCallHandler.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/CallHandlers/UnaryServerCallHandler.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Google.Api; using Grpc.Core; using Grpc.Shared.Server; using Microsoft.AspNetCore.Http; @@ -42,8 +43,15 @@ protected override async Task HandleCallAsyncCore(HttpContext httpContext, JsonT throw new RpcException(new Status(StatusCode.Cancelled, "No message returned from method.")); } - serverCallContext.EnsureResponseHeaders(); - - await JsonRequestHelpers.SendMessage(serverCallContext, SerializerOptions, response, CancellationToken.None); + if (response is HttpBody httpBody) + { + serverCallContext.EnsureResponseHeaders(httpBody.ContentType); + await serverCallContext.HttpContext.Response.Body.WriteAsync(httpBody.Data.Memory); + } + else + { + serverCallContext.EnsureResponseHeaders(); + await JsonRequestHelpers.SendMessage(serverCallContext, SerializerOptions, response, CancellationToken.None); + } } } diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpContextStreamWriter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpContextStreamWriter.cs index 04194a5f2350..baa30ad7a6ce 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpContextStreamWriter.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/HttpContextStreamWriter.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Google.Api; using Grpc.Core; namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal; @@ -85,7 +86,17 @@ private async Task WriteAsyncCore(TResponse message, CancellationToken cancellat private async Task WriteMessageAndDelimiter(TResponse message, CancellationToken cancellationToken) { - await JsonRequestHelpers.SendMessage(_context, _serializerOptions, message, cancellationToken); + if (message is HttpBody httpBody) + { + _context.EnsureResponseHeaders(httpBody.ContentType); + await _context.HttpContext.Response.Body.WriteAsync(httpBody.Data.Memory, cancellationToken); + } + else + { + _context.EnsureResponseHeaders(); + await JsonRequestHelpers.SendMessage(_context, _serializerOptions, message, cancellationToken); + } + await _context.HttpContext.Response.Body.WriteAsync(GrpcProtocolConstants.StreamingDelimiter, cancellationToken); } diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/MessageConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/MessageConverter.cs index ec51deb662ba..574cc8a56482 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/MessageConverter.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/MessageConverter.cs @@ -29,7 +29,7 @@ public override TMessage Read( if (reader.TokenType != JsonTokenType.StartObject) { - throw new InvalidOperationException($"Unexpected JSON token: {reader.TokenType}"); + throw new JsonException($"Unexpected JSON token: {reader.TokenType}"); } while (reader.Read()) diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs index fa99306b9168..3507363c96dc 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonRequestHelpers.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections; +using System.Diagnostics; using System.Linq; using System.Text; using System.Text.Json; +using Google.Api; using Google.Protobuf; using Google.Protobuf.Reflection; using Grpc.Core; @@ -13,6 +15,7 @@ using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; @@ -85,7 +88,7 @@ public static (Stream stream, bool usesTranscodingStream) GetStream(Stream inner } } - public static async Task SendErrorResponse(HttpResponse response, Encoding encoding, Status status, JsonSerializerOptions options) + public static async ValueTask SendErrorResponse(HttpResponse response, Encoding encoding, Status status, JsonSerializerOptions options) { if (!response.HasStarted) { @@ -147,7 +150,7 @@ public static int MapStatusCodeToHttpStatus(StatusCode statusCode) return StatusCodes.Status500InternalServerError; } - public static async Task WriteResponseMessage(HttpResponse response, Encoding encoding, object responseBody, JsonSerializerOptions options, CancellationToken cancellationToken) + public static async ValueTask WriteResponseMessage(HttpResponse response, Encoding encoding, object responseBody, JsonSerializerOptions options, CancellationToken cancellationToken) { var (stream, usesTranscodingStream) = GetStream(response.Body, encoding); @@ -164,7 +167,7 @@ public static async Task WriteResponseMessage(HttpResponse response, Encoding en } } - public static async Task ReadMessage(JsonTranscodingServerCallContext serverCallContext, JsonSerializerOptions serializerOptions) where TRequest : class + public static async ValueTask ReadMessage(JsonTranscodingServerCallContext serverCallContext, JsonSerializerOptions serializerOptions) where TRequest : class { try { @@ -173,65 +176,75 @@ public static async Task ReadMessage(JsonTranscodingServerCa IMessage requestMessage; if (serverCallContext.DescriptorInfo.BodyDescriptor != null) { - if (!serverCallContext.IsJsonRequestContent) + Type type; + object bodyContent; + + if (serverCallContext.DescriptorInfo.BodyDescriptor.FullName == HttpBody.Descriptor.FullName) { - GrpcServerLog.UnsupportedRequestContentType(serverCallContext.Logger, serverCallContext.HttpContext.Request.ContentType); - throw new RpcException(new Status(StatusCode.InvalidArgument, "Request content-type of application/json is required.")); + type = typeof(HttpBody); + + bodyContent = await ReadHttpBodyAsync(serverCallContext); } + else + { + if (!serverCallContext.IsJsonRequestContent) + { + GrpcServerLog.UnsupportedRequestContentType(serverCallContext.Logger, serverCallContext.HttpContext.Request.ContentType); + throw new InvalidOperationException($"Unable to read the request as JSON because the request content type '{serverCallContext.HttpContext.Request.ContentType}' is not a known JSON content type."); + } - var (stream, usesTranscodingStream) = GetStream(serverCallContext.HttpContext.Request.Body, serverCallContext.RequestEncoding); + var (stream, usesTranscodingStream) = GetStream(serverCallContext.HttpContext.Request.Body, serverCallContext.RequestEncoding); - try - { - if (serverCallContext.DescriptorInfo.BodyDescriptorRepeated) + try { - requestMessage = (IMessage)Activator.CreateInstance(); + if (serverCallContext.DescriptorInfo.BodyDescriptorRepeated) + { + requestMessage = (IMessage)Activator.CreateInstance(); - // TODO: JsonSerializer currently doesn't support deserializing values onto an existing object or collection. - // Either update this to use new functionality in JsonSerializer or improve work-around perf. - var type = JsonConverterHelper.GetFieldType(serverCallContext.DescriptorInfo.BodyFieldDescriptors.Last()); - var listType = typeof(List<>).MakeGenericType(type); + // TODO: JsonSerializer currently doesn't support deserializing values onto an existing object or collection. + // Either update this to use new functionality in JsonSerializer or improve work-around perf. + type = JsonConverterHelper.GetFieldType(serverCallContext.DescriptorInfo.BodyFieldDescriptors.Last()); + type = typeof(List<>).MakeGenericType(type); - GrpcServerLog.DeserializingMessage(serverCallContext.Logger, listType); - var repeatedContent = (IList)(await JsonSerializer.DeserializeAsync(stream, listType, serializerOptions))!; + GrpcServerLog.DeserializingMessage(serverCallContext.Logger, type); - ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, serverCallContext.DescriptorInfo.BodyFieldDescriptors, repeatedContent); - } - else - { - IMessage bodyContent; + bodyContent = (await JsonSerializer.DeserializeAsync(stream, type, serializerOptions))!; - try - { - GrpcServerLog.DeserializingMessage(serverCallContext.Logger, serverCallContext.DescriptorInfo.BodyDescriptor.ClrType); - bodyContent = (IMessage)(await JsonSerializer.DeserializeAsync(stream, serverCallContext.DescriptorInfo.BodyDescriptor.ClrType, serializerOptions))!; + if (bodyContent == null) + { + throw new InvalidOperationException($"Unable to deserialize null to {type.Name}."); + } } - catch (JsonException) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, "Request JSON payload is not correctly formatted.")); - } - catch (Exception exception) + else { - throw new RpcException(new Status(StatusCode.InvalidArgument, exception.Message)); - } + type = serverCallContext.DescriptorInfo.BodyDescriptor.ClrType; - if (serverCallContext.DescriptorInfo.BodyFieldDescriptors != null) - { - requestMessage = (IMessage)Activator.CreateInstance(); - ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, serverCallContext.DescriptorInfo.BodyFieldDescriptors, bodyContent!); // TODO - check nullability + GrpcServerLog.DeserializingMessage(serverCallContext.Logger, type); + bodyContent = (IMessage)(await JsonSerializer.DeserializeAsync(stream, serverCallContext.DescriptorInfo.BodyDescriptor.ClrType, serializerOptions))!; } - else + } + finally + { + if (usesTranscodingStream) { - requestMessage = bodyContent; + await stream.DisposeAsync(); } } } - finally + + if (serverCallContext.DescriptorInfo.BodyFieldDescriptors != null) + { + requestMessage = (IMessage)Activator.CreateInstance(); + ServiceDescriptorHelpers.RecursiveSetValue(requestMessage, serverCallContext.DescriptorInfo.BodyFieldDescriptors, bodyContent); // TODO - check nullability + } + else { - if (usesTranscodingStream) + if (bodyContent == null) { - await stream.DisposeAsync(); + throw new InvalidOperationException($"Unable to deserialize null to {type.Name}."); } + + requestMessage = (IMessage)bodyContent; } } else @@ -265,11 +278,60 @@ public static async Task ReadMessage(JsonTranscodingServerCa GrpcServerLog.ReceivedMessage(serverCallContext.Logger); return (TRequest)requestMessage; } + catch (JsonException ex) + { + GrpcServerLog.ErrorReadingMessage(serverCallContext.Logger, ex); + throw new RpcException(new Status(StatusCode.InvalidArgument, "Request JSON payload is not correctly formatted.", ex)); + } catch (Exception ex) { GrpcServerLog.ErrorReadingMessage(serverCallContext.Logger, ex); - throw; + throw new RpcException(new Status(StatusCode.InvalidArgument, ex.Message, ex)); + } + } + + private static async ValueTask ReadHttpBodyAsync(JsonTranscodingServerCallContext serverCallContext) + { + var httpBody = (IMessage)Activator.CreateInstance(serverCallContext.DescriptorInfo.BodyDescriptor!.ClrType)!; + + var contentType = serverCallContext.HttpContext.Request.ContentType; + if (contentType != null) + { + httpBody.Descriptor.Fields[HttpBody.ContentTypeFieldNumber].Accessor.SetValue(httpBody, contentType); } + + var data = await ReadDataAsync(serverCallContext); + httpBody.Descriptor.Fields[HttpBody.DataFieldNumber].Accessor.SetValue(httpBody, UnsafeByteOperations.UnsafeWrap(data)); + + return httpBody; + } + + private static async ValueTask ReadDataAsync(JsonTranscodingServerCallContext serverCallContext) + { + // Buffer to disk if content is larger than 30Kb. + // Based on value in XmlSerializer and NewtonsoftJson input formatters. + const int DefaultMemoryThreshold = 1024 * 30; + + var memoryThreshold = DefaultMemoryThreshold; + var contentLength = serverCallContext.HttpContext.Request.ContentLength.GetValueOrDefault(); + if (contentLength > 0 && contentLength < memoryThreshold) + { + // If the Content-Length is known and is smaller than the default buffer size, use it. + memoryThreshold = (int)contentLength; + } + + using var fs = new FileBufferingReadStream(serverCallContext.HttpContext.Request.Body, memoryThreshold); + + // Read the request body into buffer. + // No explicit cancellation token. Request body uses underlying request aborted token. + await fs.DrainAsync(CancellationToken.None); + fs.Seek(0, SeekOrigin.Begin); + + var data = new byte[fs.Length]; + var read = fs.Read(data); + Debug.Assert(read == data.Length); + + return data; } private static List? GetPathDescriptors(JsonTranscodingServerCallContext serverCallContext, IMessage requestMessage, string path) @@ -281,7 +343,7 @@ public static async Task ReadMessage(JsonTranscodingServerCa }); } - public static async Task SendMessage(JsonTranscodingServerCallContext serverCallContext, JsonSerializerOptions serializerOptions, TResponse message, CancellationToken cancellationToken) where TResponse : class + public static async ValueTask SendMessage(JsonTranscodingServerCallContext serverCallContext, JsonSerializerOptions serializerOptions, TResponse message, CancellationToken cancellationToken) where TResponse : class { var response = serverCallContext.HttpContext.Response; diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonTranscodingServerCallContext.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonTranscodingServerCallContext.cs index f13ef38431fe..c99f44635470 100644 --- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonTranscodingServerCallContext.cs +++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/JsonTranscodingServerCallContext.cs @@ -225,12 +225,12 @@ protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) return HttpContext.Response.BodyWriter.FlushAsync().GetAsTask(); } - internal void EnsureResponseHeaders() + internal void EnsureResponseHeaders(string? contentType = null) { if (!HttpContext.Response.HasStarted) { HttpContext.Response.StatusCode = StatusCodes.Status200OK; - HttpContext.Response.ContentType = MediaType.ReplaceEncoding("application/json", RequestEncoding); + HttpContext.Response.ContentType = contentType ?? MediaType.ReplaceEncoding("application/json", RequestEncoding); } } } diff --git a/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs b/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs index 1f537cfbf3bc..f18b0a2aec12 100644 --- a/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs +++ b/src/Grpc/JsonTranscoding/src/Shared/ServiceDescriptorHelpers.cs @@ -24,8 +24,11 @@ using Google.Api; using Google.Protobuf; using Google.Protobuf.Reflection; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.Primitives; +using Type = System.Type; namespace Grpc.Shared; @@ -165,6 +168,11 @@ public static bool TryResolveDescriptors(MessageDescriptor messageDescriptor, st case FieldType.Message: if (IsWrapperType(descriptor.MessageType)) { + if (value == null) + { + return null; + } + return ConvertValue(value, descriptor.MessageType.FindFieldByName("value")); } break; @@ -219,7 +227,17 @@ public static void RecursiveSetValue(IMessage currentValue, List + + diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Proto/httpbody.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Proto/httpbody.proto new file mode 100644 index 000000000000..cfef5f7b08f9 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/Proto/httpbody.proto @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +syntax = "proto3"; + +import "google/api/annotations.proto"; +import "google/api/httpbody.proto"; + +package transcoding; + +service HttpBodyService { + rpc HelloWorld(HelloWorldRequest) returns (google.api.HttpBody) { + option (google.api.http) = { + get: "/helloworld" + }; + } +} + +message HttpBodySubField { + string name = 1; + google.api.HttpBody sub = 2; +} + +message NestedHttpBodySubField { + string name = 1; + HttpBodySubField sub = 2; +} + +message HelloWorldRequest { + string name = 1; +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs index 9e0ed1a9b5e7..a6dc6c43c61d 100644 --- a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ServerStreamingServerCallHandlerTests.cs @@ -5,6 +5,8 @@ using System.IO.Pipelines; using System.Text; using System.Text.Json; +using System.Xml.Linq; +using Google.Api; using Google.Protobuf; using Google.Protobuf.Reflection; using Grpc.AspNetCore.Server; @@ -55,6 +57,9 @@ public async Task HandleCallAsync_WriteMultipleMessages_Returned() var callTask = callHandler.HandleCallAsync(httpContext); // Assert + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", httpContext.Response.ContentType); + var line1 = await ReadLineAsync(pipe.Reader).DefaultTimeout(); using var responseJson1 = JsonDocument.Parse(line1!); Assert.Equal("Hello TestName! 1", responseJson1.RootElement.GetProperty("message").GetString()); @@ -141,6 +146,62 @@ public async Task HandleCallAsync_ErrorWithDetailedErrors_DetailedErrorResponse( await callTask.DefaultTimeout(); } + [Fact] + public async Task HandleCallAsync_HttpBody_WriteMultipleMessages_Returned() + { + // Arrange + var syncPoint = new SyncPoint(); + + ServerStreamingServerMethod invoker = async (s, r, w, c) => + { + await w.WriteAsync(new HttpBody + { + ContentType = "application/xml", + Data = ByteString.CopyFrom(Encoding.UTF8.GetBytes($"Hello {r.Name} 1")) + }); + await syncPoint.WaitToContinue(); + await w.WriteAsync(new HttpBody + { + ContentType = "application/xml", + Data = ByteString.CopyFrom(Encoding.UTF8.GetBytes($"Hello {r.Name} 2")) + }); + }; + + var pipe = new Pipe(); + + var routeParameterDescriptors = new Dictionary> + { + ["name"] = new List(new[] { HelloRequest.Descriptor.FindFieldByNumber(HelloRequest.NameFieldNumber) }) + }; + var descriptorInfo = TestHelpers.CreateDescriptorInfo(routeParameterDescriptors: routeParameterDescriptors); + var callHandler = CreateCallHandler( + invoker, + CreateServiceMethod("HttpResponseBody", HelloRequest.Parser, HttpBody.Parser), + descriptorInfo: descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(bodyStream: pipe.Writer.AsStream()); + httpContext.Request.RouteValues["name"] = "TestName!"; + + // Act + var callTask = callHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.Equal("application/xml", httpContext.Response.ContentType); + + var line1 = await ReadLineAsync(pipe.Reader).DefaultTimeout(); + var responseXml1 = XDocument.Parse(line1!); + Assert.Equal("Hello TestName! 1", (string)responseXml1.Element("message")!); + + await syncPoint.WaitForSyncPoint().DefaultTimeout(); + syncPoint.Continue(); + + var line2 = await ReadLineAsync(pipe.Reader).DefaultTimeout(); + var responseXml2 = XDocument.Parse(line2!); + Assert.Equal("Hello TestName! 2", (string)responseXml2.Element("message")!); + + await callTask.DefaultTimeout(); + } + public async Task ReadLineAsync(PipeReader pipeReader) { string? str; @@ -187,8 +248,27 @@ private ServerStreamingServerCallHandler invoker, CallHandlerDescriptorInfo? descriptorInfo = null, List<(Type Type, object[] Args)>? interceptors = null, - GrpcJsonTranscodingOptions? JsonTranscodingOptions = null, + GrpcJsonTranscodingOptions? jsonTranscodingOptions = null, + GrpcServiceOptions? serviceOptions = null) + { + return CreateCallHandler( + invoker, + CreateServiceMethod("TestMethodName", HelloRequest.Parser, HelloReply.Parser), + descriptorInfo, + interceptors, + jsonTranscodingOptions, + serviceOptions); + } + + private ServerStreamingServerCallHandler CreateCallHandler( + ServerStreamingServerMethod invoker, + Method method, + CallHandlerDescriptorInfo? descriptorInfo = null, + List<(Type Type, object[] Args)>? interceptors = null, + GrpcJsonTranscodingOptions? jsonTranscodingOptions = null, GrpcServiceOptions? serviceOptions = null) + where TRequest : class, IMessage + where TResponse : class, IMessage { serviceOptions ??= new GrpcServiceOptions(); if (interceptors != null) @@ -199,16 +279,16 @@ private ServerStreamingServerCallHandler( + var callInvoker = new ServerStreamingServerMethodInvoker( invoker, - CreateServiceMethod("TestMethodName", HelloRequest.Parser, HelloReply.Parser), + method, MethodOptions.Create(new[] { serviceOptions }), new TestGrpcServiceActivator()); - var jsonSettings = JsonTranscodingOptions?.JsonSettings ?? new GrpcJsonSettings() { WriteIndented = false }; - var jsonContext = new JsonContext(jsonSettings, JsonTranscodingOptions?.TypeRegistry ?? TypeRegistry.Empty); + var jsonSettings = jsonTranscodingOptions?.JsonSettings ?? new GrpcJsonSettings() { WriteIndented = false }; + var jsonContext = new JsonContext(jsonSettings, jsonTranscodingOptions?.TypeRegistry ?? TypeRegistry.Empty); - return new ServerStreamingServerCallHandler( + return new ServerStreamingServerCallHandler( callInvoker, LoggerFactory, descriptorInfo ?? TestHelpers.CreateDescriptorInfo(), diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpBodyService.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpBodyService.cs new file mode 100644 index 000000000000..db732a97d3c3 --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/TestObjects/Services/HttpBodyService.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Google.Api; +using Google.Protobuf; +using Grpc.Core; +using Transcoding; + +namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests.TestObjects; + +public class HttpBodyService : Transcoding.HttpBodyService.HttpBodyServiceBase +{ + public override Task HelloWorld(HelloWorldRequest request, ServerCallContext context) + { + return Task.FromResult(new HttpBody + { + ContentType = "application/xml", + Data = ByteString.CopyFrom(Encoding.UTF8.GetBytes(@"Hello world")) + }); + } +} diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs index eeaccf442993..21855fb25f5b 100644 --- a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/UnaryServerCallHandlerTests.cs @@ -3,6 +3,8 @@ using System.Text; using System.Text.Json; +using System.Xml.Linq; +using Google.Api; using Google.Protobuf; using Google.Protobuf.Collections; using Google.Protobuf.Reflection; @@ -61,6 +63,9 @@ public async Task HandleCallAsync_MatchingRouteValue_SetOnRequestMessage() await unaryServerCallHandler.HandleCallAsync(httpContext); // Assert + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", httpContext.Response.ContentType); + Assert.NotNull(request); Assert.Equal("TestName!", request!.Name); Assert.Equal("Subfield!", request!.Sub.Subfield); @@ -412,8 +417,11 @@ public async Task HandleCallAsync_SuccessfulResponse_DefaultValuesInResponseJson [Theory] [InlineData("{malformed_json}", "Request JSON payload is not correctly formatted.")] + [InlineData("[malformed_json]", "Request JSON payload is not correctly formatted.")] + [InlineData("[1]", "Request JSON payload is not correctly formatted.")] + [InlineData("1", "Request JSON payload is not correctly formatted.")] + [InlineData("null", "Unable to deserialize null to HelloRequest.")] [InlineData("{\"name\": 1234}", "Request JSON payload is not correctly formatted.")] - //[InlineData("{\"abcd\": 1234}", "Unknown field: abcd")] public async Task HandleCallAsync_MalformedRequestBody_BadRequestReturned(string json, string expectedError) { // Arrange @@ -441,6 +449,45 @@ public async Task HandleCallAsync_MalformedRequestBody_BadRequestReturned(string Assert.Equal((int)StatusCode.InvalidArgument, responseJson.RootElement.GetProperty("code").GetInt32()); } + [Theory] + [InlineData("{malformed_json}", "Request JSON payload is not correctly formatted.")] + [InlineData("[malformed_json]", "Request JSON payload is not correctly formatted.")] + [InlineData("1", "Request JSON payload is not correctly formatted.")] + [InlineData("null", "Unable to deserialize null to List`1.")] + [InlineData("{\"name\": 1234}", "Request JSON payload is not correctly formatted.")] + public async Task HandleCallAsync_MalformedRequestBody_RepeatedBody_BadRequestReturned(string json, string expectedError) + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + return Task.FromResult(new HelloReply()); + }; + + ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "repeated_strings", out var bodyFieldDescriptors); + + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + bodyDescriptor: HelloRequest.Types.SubMessage.Descriptor, + bodyDescriptorRepeated: true, + bodyFieldDescriptors: bodyFieldDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo); + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); + httpContext.Request.ContentType = "application/json"; + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(400, httpContext.Response.StatusCode); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal(expectedError, responseJson.RootElement.GetProperty("message").GetString()); + Assert.Equal(expectedError, responseJson.RootElement.GetProperty("error").GetString()); + Assert.Equal((int)StatusCode.InvalidArgument, responseJson.RootElement.GetProperty("code").GetInt32()); + } + [Theory] [InlineData(null)] [InlineData("text/html")] @@ -456,7 +503,7 @@ public async Task HandleCallAsync_BadContentType_BadRequestReturned(string conte invoker, descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HelloRequest.Descriptor)); var httpContext = TestHelpers.CreateHttpContext(); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}")); + httpContext.Request.Body = new MemoryStream("{}"u8); httpContext.Request.ContentType = contentType; // Act await unaryServerCallHandler.HandleCallAsync(httpContext); @@ -464,7 +511,7 @@ public async Task HandleCallAsync_BadContentType_BadRequestReturned(string conte // Assert Assert.Equal(400, httpContext.Response.StatusCode); - var expectedError = "Request content-type of application/json is required."; + var expectedError = $"Unable to read the request as JSON because the request content type '{contentType}' is not a known JSON content type."; httpContext.Response.Body.Seek(0, SeekOrigin.Begin); using var responseJson = JsonDocument.Parse(httpContext.Response.Body); Assert.Equal(expectedError, responseJson.RootElement.GetProperty("message").GetString()); @@ -548,6 +595,306 @@ public async Task HandleCallAsync_StatusSet_StatusReturned() Assert.Equal((int)StatusCode.Unauthenticated, responseJson.RootElement.GetProperty("code").GetInt32()); } + [Fact] + public async Task HandleCallAsync_HttpBodyRequest_RawRequestAvailable() + { + // Arrange + string? requestContentType = null; + byte[]? requestData = null; + UnaryServerMethod invoker = (s, r, c) => + { + requestContentType = r.ContentType; + requestData = r.Data.ToByteArray(); + + var responseXml = XDocument.Load(new MemoryStream(requestData)); + var name = (string)responseXml.Element("name")!; + + return Task.FromResult(new HelloReply { Message = $"Hello {name}!" }); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + CreateServiceMethod("HttpRequestBody", HttpBody.Parser, HelloReply.Parser), + descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HttpBody.Descriptor)); + var requestContent = new XDocument(new XElement("name", "World")).ToString(); + + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.ContentType = "application/xml"; + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestContent)); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal("application/xml", requestContentType); + Assert.Equal(requestContent, Encoding.UTF8.GetString(requestData!)); + + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", httpContext.Response.ContentType); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal(@"Hello World!", responseJson.RootElement.GetProperty("message").GetString()); + } + + [Theory] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(16 * 1024, false)] + [InlineData(16 * 1024, true)] + [InlineData(1024 * 1024, false)] + [InlineData(1024 * 1024, true)] + public async Task HandleCallAsync_HttpBodyRequestLarge_RawRequestAvailable(int requestSize, bool sendContentLength) + { + // Arrange + string? requestContentType = null; + byte[]? requestData = null; + UnaryServerMethod invoker = (s, r, c) => + { + requestContentType = r.ContentType; + requestData = r.Data.ToByteArray(); + + return Task.FromResult(new HelloReply { Message = $"Hello {requestData.Length}!" }); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + CreateServiceMethod("HttpRequestBody", HttpBody.Parser, HelloReply.Parser), + descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HttpBody.Descriptor)); + + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.ContentType = "application/octet-stream"; + + var requestContent = new byte[requestSize]; + for (var i = 0; i < requestContent.Length; i++) + { + requestContent[i] = (byte)(i % 10); + } + httpContext.Request.Body = new MemoryStream(requestContent); + if (sendContentLength) + { + httpContext.Request.ContentLength = requestSize; + } + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal("application/octet-stream", requestContentType); + Assert.Equal(requestContent, requestData); + + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", httpContext.Response.ContentType); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal($"Hello {requestContent.Length}!", responseJson.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task HandleCallAsync_NullBody_WrapperType_Error() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + CreateServiceMethod("Int32ValueBody", Int32Value.Parser, HelloReply.Parser), + descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: Int32Value.Descriptor)); + + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.ContentType = "application/json"; + httpContext.Request.Body = new MemoryStream("null"u8); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal("Unable to deserialize null to Int32Value.", responseJson.RootElement.GetProperty("message").GetString()); + } + + [Theory] + [InlineData("null", null)] + [InlineData("1", 1.0f)] + [InlineData("1.1", 1.1f)] + [InlineData(@"""NaN""", float.NaN)] + public async Task HandleCallAsync_NestedWrapperType_Success(string requestJson, float? expectedValue) + { + // Arrange + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + UnaryServerMethod invoker = (s, r, c) => + { + tcs.SetResult(r.Wrappers.FloatValue); + return Task.FromResult(new HelloReply()); + }; + + Assert.True(ServiceDescriptorHelpers.TryResolveDescriptors(HelloRequest.Descriptor, "wrappers.float_value", out var bodyFieldDescriptors)); + + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + bodyDescriptor: FloatValue.Descriptor, + bodyFieldDescriptors: bodyFieldDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + descriptorInfo); + + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.ContentType = "application/json"; + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestJson)); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + var value = await tcs.Task.DefaultTimeout(); + Assert.Equal(expectedValue, value); + } + + [Fact] + public async Task HandleCallAsync_HttpBodyRequest_NoBody_RawRequestAvailable() + { + // Arrange + string? requestContentType = null; + byte[]? requestData = null; + UnaryServerMethod invoker = (s, r, c) => + { + requestContentType = r.ContentType; + requestData = r.Data.ToByteArray(); + + return Task.FromResult(new HelloReply()); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + CreateServiceMethod("HttpRequestBody", HttpBody.Parser, HelloReply.Parser), + descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HttpBody.Descriptor)); + var requestContent = new XDocument(new XElement("name", "World")).ToString(); + + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal("", requestContentType); + Assert.Empty(requestData!); + } + + [Fact] + public async Task HandleCallAsync_SubHttpBodyRequest_RawRequestAvailable() + { + // Arrange + HttpBodySubField? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); + }; + + ServiceDescriptorHelpers.TryResolveDescriptors(HttpBodySubField.Descriptor, "sub", out var bodyFieldDescriptors); + + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + bodyDescriptor: HttpBody.Descriptor, + bodyFieldDescriptors: bodyFieldDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + CreateServiceMethod("HttpRequestBody", HttpBodySubField.Parser, HelloReply.Parser), + descriptorInfo); + var requestContent = new XDocument(new XElement("name", "World")).ToString(); + + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestContent)); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "QueryStringTestName!" + }); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("QueryStringTestName!", request!.Name); + Assert.Equal("", request!.Sub.ContentType); + Assert.Equal(requestContent, Encoding.UTF8.GetString(request!.Sub.Data.ToByteArray())); + } + + [Fact] + public async Task HandleCallAsync_NestedSubHttpBodyRequest_RawRequestAvailable() + { + // Arrange + NestedHttpBodySubField? request = null; + UnaryServerMethod invoker = (s, r, c) => + { + request = r; + return Task.FromResult(new HelloReply { Message = $"Hello {r.Name}" }); + }; + + ServiceDescriptorHelpers.TryResolveDescriptors(NestedHttpBodySubField.Descriptor, "sub.sub", out var bodyFieldDescriptors); + + var descriptorInfo = TestHelpers.CreateDescriptorInfo( + bodyDescriptor: HttpBody.Descriptor, + bodyFieldDescriptors: bodyFieldDescriptors); + var unaryServerCallHandler = CreateCallHandler( + invoker, + CreateServiceMethod("HttpRequestBody", NestedHttpBodySubField.Parser, HelloReply.Parser), + descriptorInfo); + var requestContent = new XDocument(new XElement("name", "World")).ToString(); + + var httpContext = TestHelpers.CreateHttpContext(); + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(requestContent)); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["name"] = "QueryStringTestName!", + ["sub.name"] = "SubQueryStringTestName!" + }); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.NotNull(request); + Assert.Equal("QueryStringTestName!", request!.Name); + Assert.Equal("SubQueryStringTestName!", request!.Sub.Name); + Assert.Equal("", request!.Sub.Sub.ContentType); + Assert.Equal(requestContent, Encoding.UTF8.GetString(request!.Sub.Sub.Data.ToByteArray())); + } + + [Fact] + public async Task HandleCallAsync_HttpBodyResponse_BodyReturned() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + return Task.FromResult(new HttpBody + { + ContentType = "application/xml", + Data = ByteString.CopyFrom("Hello world"u8) + }); + }; + + var unaryServerCallHandler = CreateCallHandler( + invoker, + CreateServiceMethod("HttpResponseBody", HelloRequest.Parser, HttpBody.Parser)); + + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.Equal("application/xml", httpContext.Response.ContentType); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + var responseXml = XDocument.Load(httpContext.Response.Body); + Assert.Equal(@"Hello world", (string)responseXml.Element("message")!); + } + [Fact] public async Task HandleCallAsync_UserState_HttpContextInUserState() { @@ -804,8 +1151,8 @@ await ExecuteUnaryHandler(httpContext => }); }); - var exceptionWrite = TestSink.Writes.Single(w => w.EventId.Name == "ErrorExecutingServiceMethod"); - Assert.Equal($"Invalid value '{value}' for enum type NestedEnum.", exceptionWrite.Exception.Message); + var exceptionWrite = TestSink.Writes.Single(w => w.EventId.Name == "RpcConnectionError"); + Assert.Equal($"Error status code 'InvalidArgument' with detail 'Invalid value '{value}' for enum type NestedEnum.' raised.", exceptionWrite.Message); } private async Task ExecuteUnaryHandler( @@ -891,7 +1238,7 @@ public async Task HandleCallAsync_Any_Success() var unaryServerCallHandler = CreateCallHandler( invoker, descriptorInfo: TestHelpers.CreateDescriptorInfo(bodyDescriptor: HelloRequest.Descriptor), - JsonTranscodingOptions: new GrpcJsonTranscodingOptions + jsonTranscodingOptions: new GrpcJsonTranscodingOptions { TypeRegistry = typeRegistry }); @@ -924,7 +1271,24 @@ private UnaryServerCallHandler invoker, CallHandlerDescriptorInfo? descriptorInfo = null, List<(Type Type, object[] Args)>? interceptors = null, - GrpcJsonTranscodingOptions? JsonTranscodingOptions = null) + GrpcJsonTranscodingOptions? jsonTranscodingOptions = null) + { + return CreateCallHandler( + invoker, + CreateServiceMethod("TestMethodName", HelloRequest.Parser, HelloReply.Parser), + descriptorInfo, + interceptors, + jsonTranscodingOptions); + } + + private UnaryServerCallHandler CreateCallHandler( + UnaryServerMethod invoker, + Method method, + CallHandlerDescriptorInfo? descriptorInfo = null, + List<(Type Type, object[] Args)>? interceptors = null, + GrpcJsonTranscodingOptions? jsonTranscodingOptions = null) + where TRequest : class, IMessage + where TResponse : class, IMessage { var serviceOptions = new GrpcServiceOptions(); if (interceptors != null) @@ -935,17 +1299,17 @@ private UnaryServerCallHandler( + var unaryServerCallInvoker = new UnaryServerMethodInvoker( invoker, - CreateServiceMethod("TestMethodName", HelloRequest.Parser, HelloReply.Parser), + method, MethodOptions.Create(new[] { serviceOptions }), new TestGrpcServiceActivator()); var jsonContext = new JsonContext( - JsonTranscodingOptions?.JsonSettings ?? new GrpcJsonSettings(), - JsonTranscodingOptions?.TypeRegistry ?? TypeRegistry.Empty); + jsonTranscodingOptions?.JsonSettings ?? new GrpcJsonSettings(), + jsonTranscodingOptions?.TypeRegistry ?? TypeRegistry.Empty); - return new UnaryServerCallHandler( + return new UnaryServerCallHandler( unaryServerCallInvoker, LoggerFactory, descriptorInfo ?? TestHelpers.CreateDescriptorInfo(), diff --git a/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/httpbody.proto b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/httpbody.proto new file mode 100644 index 000000000000..00c80aba9d7d --- /dev/null +++ b/src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/google/api/httpbody.proto @@ -0,0 +1,81 @@ +// Copyright 2015 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "google/protobuf/any.proto"; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/httpbody;httpbody"; +option java_multiple_files = true; +option java_outer_classname = "HttpBodyProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Message that represents an arbitrary HTTP body. It should only be used for +// payload formats that can't be represented as JSON, such as raw binary or +// an HTML page. +// +// +// This message can be used both in streaming and non-streaming API methods in +// the request as well as the response. +// +// It can be used as a top-level request field, which is convenient if one +// wants to extract parameters from either the URL or HTTP template into the +// request fields and also want access to the raw HTTP body. +// +// Example: +// +// message GetResourceRequest { +// // A unique request id. +// string request_id = 1; +// +// // The raw HTTP body is bound to this field. +// google.api.HttpBody http_body = 2; +// +// } +// +// service ResourceService { +// rpc GetResource(GetResourceRequest) +// returns (google.api.HttpBody); +// rpc UpdateResource(google.api.HttpBody) +// returns (google.protobuf.Empty); +// +// } +// +// Example with streaming methods: +// +// service CaldavService { +// rpc GetCalendar(stream google.api.HttpBody) +// returns (stream google.api.HttpBody); +// rpc UpdateCalendar(stream google.api.HttpBody) +// returns (stream google.api.HttpBody); +// +// } +// +// Use of this type only changes how the request and response bodies are +// handled, all other features will continue to work unchanged. +message HttpBody { + // The HTTP Content-Type header value specifying the content type of the body. + string content_type = 1; + + // The HTTP request/response body as raw binary. + bytes data = 2; + + // Application specific response metadata. Must be set in the first response + // for streaming APIs. + repeated google.protobuf.Any extensions = 3; +}