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 ef0ec6d3f0cc..11db47555fbb 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 @@ -23,6 +23,8 @@ internal static class JsonRequestHelpers public const string JsonContentType = "application/json"; public const string JsonContentTypeWithCharset = "application/json; charset=utf-8"; + public const string StatusDetailsTrailerName = "grpc-status-details-bin"; + public static bool HasJsonContentType(HttpRequest request, out StringSegment charset) { ArgumentNullException.ThrowIfNull(request); @@ -82,7 +84,7 @@ public static (Stream stream, bool usesTranscodingStream) GetStream(Stream inner } } - public static async ValueTask SendErrorResponse(HttpResponse response, Encoding encoding, Status status, JsonSerializerOptions options) + public static async ValueTask SendErrorResponse(HttpResponse response, Encoding encoding, Metadata trailers, Status status, JsonSerializerOptions options) { if (!response.HasStarted) { @@ -90,13 +92,31 @@ public static async ValueTask SendErrorResponse(HttpResponse response, Encoding response.ContentType = MediaType.ReplaceEncoding("application/json", encoding); } - var e = new Google.Rpc.Status + var e = GetStatusDetails(trailers) ?? new Google.Rpc.Status { Message = status.Detail, Code = (int)status.StatusCode }; await WriteResponseMessage(response, encoding, e, options, CancellationToken.None); + + static Google.Rpc.Status? GetStatusDetails(Metadata trailers) + { + var statusDetails = trailers.Get(StatusDetailsTrailerName); + if (statusDetails?.IsBinary == true) + { + try + { + return Google.Rpc.Status.Parser.ParseFrom(statusDetails.ValueBytes); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Error when parsing the '{StatusDetailsTrailerName}' trailer.", ex); + } + } + + return null; + } } public static int MapStatusCodeToHttpStatus(StatusCode statusCode) 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 a90e192510b9..c773342a11d0 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 @@ -21,6 +21,7 @@ internal sealed class JsonTranscodingServerCallContext : ServerCallContext, ISer private static readonly AuthContext UnauthenticatedContext = new AuthContext(null, new Dictionary>()); private readonly IMethod _method; + private Metadata? _responseTrailers; public HttpContext HttpContext { get; } public MethodOptions Options { get; } @@ -109,6 +110,10 @@ internal async Task ProcessHandlerErrorAsync(Exception ex, string method, bool i GrpcServerLog.RpcConnectionError(Logger, rpcException.StatusCode, rpcException.Status.Detail, rpcException.Status.DebugException); status = rpcException.Status; + foreach (var entry in rpcException.Trailers) + { + ResponseTrailers.Add(entry); + } } else { @@ -121,7 +126,7 @@ internal async Task ProcessHandlerErrorAsync(Exception ex, string method, bool i status = new Status(StatusCode.Unknown, message, ex); } - await JsonRequestHelpers.SendErrorResponse(HttpContext.Response, RequestEncoding, status, options); + await JsonRequestHelpers.SendErrorResponse(HttpContext.Response, RequestEncoding, ResponseTrailers, status, options); if (isStreaming) { await HttpContext.Response.Body.WriteAsync(GrpcProtocolConstants.StreamingDelimiter); @@ -164,7 +169,7 @@ protected override Metadata RequestHeadersCore protected override CancellationToken CancellationTokenCore => HttpContext.RequestAborted; - protected override Metadata ResponseTrailersCore => throw new NotImplementedException(); + protected override Metadata ResponseTrailersCore => _responseTrailers ??= new(); protected override Status StatusCore { get; set; } 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 561a002bb8f7..aa882cd68503 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 @@ -871,6 +871,165 @@ public async Task HandleCallAsync_RpcExceptionThrown_StatusReturned() Assert.Equal(debugException, exceptionWrite.Exception); } + [Fact] + public async Task HandleCallAsync_RpcExceptionThrown_StatusDetailsReturned() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + var debugInfo = new Google.Rpc.DebugInfo + { + Detail = "This is some debugging information" + }; + + var requestInfo = new Google.Rpc.RequestInfo + { + RequestId = "request-id" + }; + + var badRequest = new Google.Rpc.BadRequest + { + FieldViolations = { new Google.Rpc.BadRequest.Types.FieldViolation { Description = "Negative", Field = "speed" } } + }; + + var status = new Google.Rpc.Status + { + Code = 123, + Message = "This is a message", + Details = + { + Any.Pack(debugInfo), + Any.Pack(requestInfo), + Any.Pack(badRequest) + } + }; + + throw new RpcException(new Status(StatusCode.InvalidArgument, "Bad request"), + new Metadata + { + { JsonRequestHelpers.StatusDetailsTrailerName, status.ToByteArray() } + }); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker, + jsonTranscodingOptions: new GrpcJsonTranscodingOptions() + { + TypeRegistry = TypeRegistry.FromMessages( + Google.Rpc.DebugInfo.Descriptor, + Google.Rpc.RequestInfo.Descriptor, + Google.Rpc.BadRequest.Descriptor) + }); + + var httpContext = TestHelpers.CreateHttpContext(); + + // 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(123, responseJson.RootElement.GetProperty("code").GetInt32()); + Assert.Equal("This is a message", responseJson.RootElement.GetProperty("message").GetString()); + + var details = responseJson.RootElement.GetProperty("details").EnumerateArray().ToArray(); + Assert.Collection(details, + static d => + { + Assert.Equal("type.googleapis.com/google.rpc.DebugInfo", d.GetProperty("@type").GetString()); + Assert.Equal("This is some debugging information", d.GetProperty("detail").GetString()); + }, + static d => + { + Assert.Equal("type.googleapis.com/google.rpc.RequestInfo", d.GetProperty("@type").GetString()); + Assert.Equal("request-id", d.GetProperty("requestId").GetString()); + }, + static d => + { + Assert.Equal("type.googleapis.com/google.rpc.BadRequest", d.GetProperty("@type").GetString()); + Assert.Equal(1, d.GetProperty("fieldViolations").GetArrayLength()); + }); + } + + [Fact] + public async Task HandleCallAsync_OtherExceptionThrown_StatusDetailsReturned() + { + // Arrange + UnaryServerMethod invoker = (s, r, c) => + { + var debugInfo = new Google.Rpc.DebugInfo + { + Detail = "This is some debugging information" + }; + + var requestInfo = new Google.Rpc.RequestInfo + { + RequestId = "request-id" + }; + + var badRequest = new Google.Rpc.BadRequest + { + FieldViolations = { new Google.Rpc.BadRequest.Types.FieldViolation { Description = "Negative", Field = "speed" } } + }; + + var status = new Google.Rpc.Status + { + Code = 123, + Message = "This is a message", + Details = + { + Any.Pack(debugInfo), + Any.Pack(requestInfo), + Any.Pack(badRequest) + } + }; + + c.ResponseTrailers.Add(JsonRequestHelpers.StatusDetailsTrailerName, status.ToByteArray()); + throw new InvalidOperationException("exception"); + }; + + var unaryServerCallHandler = CreateCallHandler(invoker, + jsonTranscodingOptions: new GrpcJsonTranscodingOptions() + { + TypeRegistry = TypeRegistry.FromMessages( + Google.Rpc.DebugInfo.Descriptor, + Google.Rpc.RequestInfo.Descriptor, + Google.Rpc.BadRequest.Descriptor) + }); + + var httpContext = TestHelpers.CreateHttpContext(); + + // Act + await unaryServerCallHandler.HandleCallAsync(httpContext); + + // Assert + Assert.Equal(500, httpContext.Response.StatusCode); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var responseJson = JsonDocument.Parse(httpContext.Response.Body); + Assert.Equal(123, responseJson.RootElement.GetProperty("code").GetInt32()); + Assert.Equal("This is a message", responseJson.RootElement.GetProperty("message").GetString()); + + var details = responseJson.RootElement.GetProperty("details").EnumerateArray().ToArray(); + Assert.Collection(details, + static d => + { + Assert.Equal("type.googleapis.com/google.rpc.DebugInfo", d.GetProperty("@type").GetString()); + Assert.Equal("This is some debugging information", d.GetProperty("detail").GetString()); + }, + static d => + { + Assert.Equal("type.googleapis.com/google.rpc.RequestInfo", d.GetProperty("@type").GetString()); + Assert.Equal("request-id", d.GetProperty("requestId").GetString()); + }, + static d => + { + Assert.Equal("type.googleapis.com/google.rpc.BadRequest", d.GetProperty("@type").GetString()); + Assert.Equal(1, d.GetProperty("fieldViolations").GetArrayLength()); + }); + } + [Fact] public async Task HandleCallAsync_OtherExceptionThrown_StatusReturned() {