diff --git a/src/Grpc.Net.ClientFactory/Internal/CallOptionsConfigurationInvoker.cs b/src/Grpc.Net.ClientFactory/Internal/CallOptionsConfigurationInvoker.cs index b910f62e9..9bf7cf0cd 100644 --- a/src/Grpc.Net.ClientFactory/Internal/CallOptionsConfigurationInvoker.cs +++ b/src/Grpc.Net.ClientFactory/Internal/CallOptionsConfigurationInvoker.cs @@ -1,4 +1,4 @@ -#region Copyright notice and license +#region Copyright notice and license // Copyright 2019 The gRPC Authors // @@ -33,6 +33,9 @@ public CallOptionsConfigurationInvoker(CallInvoker innerInvoker, IList _innerInvoker; + private CallOptions ResolveCallOptions(CallOptions callOptions) { var context = new CallOptionsContext(callOptions, _serviceProvider); diff --git a/src/Grpc.Net.ClientFactory/Internal/GrpcCallInvokerFactory.cs b/src/Grpc.Net.ClientFactory/Internal/GrpcCallInvokerFactory.cs index 3e4bf9e2c..8b46cbb86 100644 --- a/src/Grpc.Net.ClientFactory/Internal/GrpcCallInvokerFactory.cs +++ b/src/Grpc.Net.ClientFactory/Internal/GrpcCallInvokerFactory.cs @@ -17,6 +17,8 @@ #endregion using System.Collections.Concurrent; +using System.Globalization; +using System.Net.Http.Headers; using Grpc.Core; using Grpc.Net.Client; using Grpc.Shared; @@ -39,6 +41,7 @@ internal class GrpcCallInvokerFactory private readonly IServiceScopeFactory _scopeFactory; private readonly ConcurrentDictionary _activeChannels; private readonly Func _invokerFactory; + private readonly ILogger _logger; public GrpcCallInvokerFactory( IServiceScopeFactory scopeFactory, @@ -57,6 +60,7 @@ public GrpcCallInvokerFactory( _scopeFactory = scopeFactory; _activeChannels = new ConcurrentDictionary(); _invokerFactory = CreateInvoker; + _logger = _loggerFactory.CreateLogger(); } public CallInvoker CreateInvoker(string name, Type type) @@ -73,12 +77,58 @@ private CallInvoker CreateInvoker(EntryKey key) try { var httpClientFactoryOptions = _httpClientFactoryOptionsMonitor.Get(name); + var clientFactoryOptions = _grpcClientFactoryOptionsMonitor.Get(name); + + // gRPC channel is configured with a handler instead of a client, so HttpClientActions aren't used directly. + // To capture HttpClient configuration, a temp HttpClient is created and configured using HttpClientActions. + // Values from the temp HttpClient are then copied to the gRPC channel. + // Only values with overlap on both types are copied so log a message about the limitations. if (httpClientFactoryOptions.HttpClientActions.Count > 0) { - throw new InvalidOperationException($"The ConfigureHttpClient method is not supported when creating gRPC clients. Unable to create client with name '{name}'."); + Log.HttpClientActionsPartiallySupported(_logger, name); + + var httpClient = new HttpClient(NullHttpMessageHandler.Instance); + foreach (var applyOptions in httpClientFactoryOptions.HttpClientActions) + { + applyOptions(httpClient); + } + + // Copy configuration from HttpClient to GrpcChannel/CallOptions. + // This configuration should be overriden by gRPC specific config methods. + if (clientFactoryOptions.Address == null) + { + clientFactoryOptions.Address = httpClient.BaseAddress; + } + + if (httpClient.DefaultRequestHeaders.Any()) + { + var defaultHeaders = httpClient.DefaultRequestHeaders.ToList(); + + // Merge DefaultRequestHeaders with CallOptions.Headers. + // Follow behavior of DefaultRequestHeaders on HttpClient when merging. + // Don't replace or add new header values if the header name has already been set. + clientFactoryOptions.CallOptionsActions.Add(callOptionsContext => + { + var metadata = callOptionsContext.CallOptions.Headers ?? new Metadata(); + foreach (var entry in defaultHeaders) + { + // grpc requires header names are lower case before being added to collection. + var resolvedKey = entry.Key.ToLower(CultureInfo.InvariantCulture); + + if (metadata.Get(resolvedKey) == null) + { + foreach (var value in entry.Value) + { + metadata.Add(resolvedKey, value); + } + } + } + + callOptionsContext.CallOptions = callOptionsContext.CallOptions.WithHeaders(metadata); + }); + } } - var clientFactoryOptions = _grpcClientFactoryOptionsMonitor.Get(name); var httpHandler = _messageHandlerFactory.CreateHandler(name); if (httpHandler == null) { @@ -190,4 +240,25 @@ public override void SetCompositeCredentials(object state, ChannelCredentials ch { } } + + private sealed class NullHttpMessageHandler : HttpMessageHandler + { + public static readonly NullHttpMessageHandler Instance = new NullHttpMessageHandler(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } + + private static class Log + { + private static readonly Action _httpClientActionsPartiallySupported = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "HttpClientActionsPartiallySupported"), "The ConfigureHttpClient method is used to configure gRPC client '{ClientName}'. ConfigureHttpClient is partially supported when creating gRPC clients and only some HttpClient properties such as BaseAddress and DefaultRequestHeaders are applied to the gRPC client."); + + public static void HttpClientActionsPartiallySupported(ILogger logger, string clientName) + { + _httpClientActionsPartiallySupported(logger, clientName, null); + } + } } diff --git a/test/Grpc.Net.ClientFactory.Tests/DefaultGrpcClientFactoryTests.cs b/test/Grpc.Net.ClientFactory.Tests/DefaultGrpcClientFactoryTests.cs index 5787bffd7..d2a58d1c4 100644 --- a/test/Grpc.Net.ClientFactory.Tests/DefaultGrpcClientFactoryTests.cs +++ b/test/Grpc.Net.ClientFactory.Tests/DefaultGrpcClientFactoryTests.cs @@ -16,6 +16,8 @@ #endregion +using System.Net; +using System.Net.Http.Headers; using Greet; using Grpc.Core; using Grpc.Net.Client.Internal; @@ -144,16 +146,77 @@ public void CreateClient_NoAddress_ThrowError() } [Test] - public void CreateClient_ConfigureHttpClient_ThrowError() + public async Task CreateClient_ConfigureHttpClient_LogMessage() { // Arrange + var testSink = new TestSink(); + Uri? requestUri = null; + HttpRequestHeaders? requestHeaders = null; + var services = new ServiceCollection(); + services.AddLogging(configure => configure.SetMinimumLevel(LogLevel.Trace)); + services.TryAddEnumerable(ServiceDescriptor.Singleton(s => new TestLoggerProvider(testSink, true))); services .AddGrpcClient() - .ConfigureHttpClient(options => options.BaseAddress = new Uri("http://contoso")) + .ConfigureHttpClient(options => + { + options.BaseAddress = new Uri("http://contoso"); + options.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", "abc"); + }) + .ConfigurePrimaryHttpMessageHandler(() => + { + return TestHttpMessageHandler.Create(async r => + { + requestUri = r.RequestUri; + requestHeaders = r.Headers; + + var streamContent = await ClientTestHelpers.CreateResponseContent(new HelloReply()).DefaultTimeout(); + return ResponseUtils.CreateResponse(HttpStatusCode.OK, streamContent); + }); + }); + + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + + var clientFactory = CreateGrpcClientFactory(serviceProvider); + + // Act + var client = clientFactory.CreateClient(nameof(TestGreeterClient)); + var response = await client.SayHelloAsync(new HelloRequest()).ResponseAsync.DefaultTimeout(); + + // Assert + Assert.AreEqual("http://contoso", client.CallInvoker.Channel.Address.OriginalString); + Assert.AreEqual(new Uri("http://contoso/greet.Greeter/SayHello"), requestUri); + Assert.AreEqual("bearer abc", requestHeaders!.GetValues("authorization").Single()); + + Assert.IsTrue(testSink.Writes.Any(w => w.EventId.Name == "HttpClientActionsPartiallySupported")); + } + + [Test] + public async Task CreateClient_ConfigureHttpClient_OverridenByGrpcConfiguration() + { + // Arrange + Uri? requestUri = null; + HttpRequestHeaders? requestHeaders = null; + + var services = new ServiceCollection(); + services + .AddGrpcClient(o => o.Address = new Uri("http://eshop")) + .ConfigureHttpClient(options => + { + options.BaseAddress = new Uri("http://contoso"); + options.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", "abc"); + options.DefaultRequestHeaders.TryAddWithoutValidation("HTTPCLIENT-KEY", "httpclient-value"); + }) .ConfigurePrimaryHttpMessageHandler(() => { - return new NullHttpHandler(); + return TestHttpMessageHandler.Create(async r => + { + requestUri = r.RequestUri; + requestHeaders = r.Headers; + + var streamContent = await ClientTestHelpers.CreateResponseContent(new HelloReply()).DefaultTimeout(); + return ResponseUtils.CreateResponse(HttpStatusCode.OK, streamContent); + }); }); var serviceProvider = services.BuildServiceProvider(validateScopes: true); @@ -161,10 +224,15 @@ public void CreateClient_ConfigureHttpClient_ThrowError() var clientFactory = CreateGrpcClientFactory(serviceProvider); // Act - var ex = Assert.Throws(() => clientFactory.CreateClient(nameof(TestGreeterClient)))!; + var client = clientFactory.CreateClient(nameof(TestGreeterClient)); + var response = await client.SayHelloAsync(new HelloRequest(), headers: new Metadata { new Metadata.Entry("authorization", "bearer 123"), new Metadata.Entry("call-key", "call-value") }).ResponseAsync.DefaultTimeout(); // Assert - Assert.AreEqual("The ConfigureHttpClient method is not supported when creating gRPC clients. Unable to create client with name 'TestGreeterClient'.", ex.Message); + Assert.AreEqual("http://eshop", client.CallInvoker.Channel.Address.OriginalString); + Assert.AreEqual(new Uri("http://eshop/greet.Greeter/SayHello"), requestUri); + Assert.AreEqual("bearer 123", requestHeaders!.GetValues("authorization").Single()); + Assert.AreEqual("httpclient-value", requestHeaders!.GetValues("httpclient-key").Single()); + Assert.AreEqual("call-value", requestHeaders!.GetValues("call-key").Single()); } #if NET462 @@ -292,6 +360,10 @@ internal class TestGreeterClient : Greeter.GreeterClient { public TestGreeterClient(CallInvoker callInvoker) : base(callInvoker) { + if (callInvoker is CallOptionsConfigurationInvoker callOptionsInvoker) + { + callInvoker = callOptionsInvoker.InnerInvoker; + } CallInvoker = (HttpClientCallInvoker)callInvoker; }