diff --git a/Directory.Packages.props b/Directory.Packages.props index a9732615..6dd2e2ac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/AwsLambda.Host/AwsLambda.Host.csproj b/src/AwsLambda.Host/AwsLambda.Host.csproj index f7656c92..a479e9c5 100644 --- a/src/AwsLambda.Host/AwsLambda.Host.csproj +++ b/src/AwsLambda.Host/AwsLambda.Host.csproj @@ -18,6 +18,7 @@ + diff --git a/src/AwsLambda.Host/Builder/Extensions/LambdaHttpClientServiceCollectionExtensions.cs b/src/AwsLambda.Host/Builder/Extensions/LambdaHttpClientServiceCollectionExtensions.cs new file mode 100644 index 00000000..20c65192 --- /dev/null +++ b/src/AwsLambda.Host/Builder/Extensions/LambdaHttpClientServiceCollectionExtensions.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace AwsLambda.Host.Builder.Extensions; + +/// +/// Extension methods for configuring the Lambda bootstrap HTTP client in an +/// . +/// +public static class LambdaHttpClientServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// Adds a custom HTTP client instance for the Lambda bootstrap runtime API. + /// The type of to register. + /// The pre-configured instance. + /// The for chaining. + /// + /// Registers a keyed singleton that will be used by the Lambda + /// bootstrap to communicate with the Lambda runtime API. This overload is useful for testing + /// scenarios where you want to inject a mock or fake HTTP client. + /// + public IServiceCollection AddLambdaBootstrapHttpClient(T client) + where T : HttpClient + { + ArgumentNullException.ThrowIfNull(services); + + services.AddKeyedSingleton(typeof(ILambdaBootstrapOrchestrator), client); + + return services; + } + + /// + /// Adds a factory for creating a custom HTTP client for the Lambda bootstrap runtime API. + /// + /// + /// A factory function that creates the instance. The function + /// receives the and service key. + /// + /// The for chaining. + /// + /// + /// Registers a keyed singleton factory that will be used by the + /// Lambda bootstrap to communicate with the Lambda runtime API. This overload provides + /// access to the dependency injection container, allowing you to resolve other services + /// when constructing the HTTP client. + /// + /// + public IServiceCollection AddLambdaBootstrapHttpClient( + Func factory + ) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddKeyedSingleton(typeof(ILambdaBootstrapOrchestrator), factory); + + return services; + } + + /// + /// Attempts to add a custom HTTP client instance for the Lambda bootstrap runtime API if one is + /// not already registered. + /// + /// The type of to register. + /// The pre-configured instance. + /// + /// Registers a keyed singleton that will be used by the Lambda + /// bootstrap to communicate with the Lambda runtime API, but only if a keyed HTTP client has + /// not already been registered. This overload is useful for testing scenarios where you want + /// to inject a mock or fake HTTP client without overwriting user-supplied configurations. + /// + public void TryAddLambdaBootstrapHttpClient(T client) + where T : HttpClient + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddKeyedSingleton(typeof(ILambdaBootstrapOrchestrator), client); + } + + /// + /// Attempts to add a factory for creating a custom HTTP client for the Lambda bootstrap runtime + /// API if one is not already registered. + /// + /// + /// A factory function that creates the instance. The function + /// receives the and service key. + /// + /// + /// + /// Registers a keyed singleton factory that will be used by the + /// Lambda bootstrap to communicate with the Lambda runtime API, but only if a keyed HTTP + /// client has not already been registered. + /// + /// + public void TryAddLambdaBootstrapHttpClient( + Func factory + ) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddKeyedSingleton( + typeof(ILambdaBootstrapOrchestrator), + factory + ); + } + } +} diff --git a/src/AwsLambda.Host/Core/Options/LambdaHostOptions.cs b/src/AwsLambda.Host/Core/Options/LambdaHostOptions.cs index c6229f5b..953c05cc 100644 --- a/src/AwsLambda.Host/Core/Options/LambdaHostOptions.cs +++ b/src/AwsLambda.Host/Core/Options/LambdaHostOptions.cs @@ -1,4 +1,5 @@ using Amazon.Lambda.RuntimeSupport.Bootstrap; +using AwsLambda.Host.Builder.Extensions; namespace AwsLambda.Host.Options; @@ -8,9 +9,17 @@ public class LambdaHostOptions /// Gets or sets an optional custom HTTP client for the Lambda bootstrap. /// /// When null, the bootstrap will create its own HTTP client for communicating with the Lambda - /// runtime API. Provide a custom client to control connection pooling, timeout settings, or other - /// HTTP-level behaviors. + /// runtime API. This property will be overridden by keyed service registrations from + /// or + /// + /// extension methods. /// + [Obsolete( + "This property will be removed in version 2.0.0. Use " + + "AddLambdaBootstrapHttpClient() or TryAddLambdaBootstrapHttpClient() extension methods " + + "instead.", + false + )] public HttpClient? BootstrapHttpClient { get; set; } = null; /// Gets or sets the options for configuring the Lambda bootstrap behavior. diff --git a/src/AwsLambda.Host/Runtime/LambdaBootstrapAdapter.cs b/src/AwsLambda.Host/Runtime/LambdaBootstrapAdapter.cs index c6f20536..6dc13276 100644 --- a/src/AwsLambda.Host/Runtime/LambdaBootstrapAdapter.cs +++ b/src/AwsLambda.Host/Runtime/LambdaBootstrapAdapter.cs @@ -1,5 +1,6 @@ using Amazon.Lambda.Core; using Amazon.Lambda.RuntimeSupport; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace AwsLambda.Host.Runtime; @@ -10,12 +11,23 @@ namespace AwsLambda.Host.Runtime; /// internal sealed class LambdaBootstrapAdapter : ILambdaBootstrapOrchestrator { + private readonly HttpClient? _httpClient; private readonly LambdaHostOptions _settings; - public LambdaBootstrapAdapter(IOptions lambdaHostSettings) + public LambdaBootstrapAdapter( + IOptions lambdaHostSettings, + [FromKeyedServices(typeof(ILambdaBootstrapOrchestrator))] HttpClient? httpClient = null + ) { ArgumentNullException.ThrowIfNull(lambdaHostSettings); + // TODO: Remove this check once ILambdaBootstrapOrchestrator.BootstrapHttpClient is removed. + // until ILambdaBootstrapOrchestrator.BootstrapHttpClient is removed, we need to check for + // it if the keyed service is NOT present. +#pragma warning disable CS0618 // Type or member is obsolete + _httpClient = httpClient ?? lambdaHostSettings.Value.BootstrapHttpClient; +#pragma warning restore CS0618 // Type or member is obsolete + _settings = lambdaHostSettings.Value; } @@ -32,10 +44,10 @@ CancellationToken stoppingToken using var wrappedHandler = HandlerWrapper.GetHandlerWrapper(handler); // Create the bootstrap based on configuration. - using var bootstrap = _settings.BootstrapHttpClient is null + using var bootstrap = _httpClient is null ? new LambdaBootstrap(wrappedHandler, _settings.BootstrapOptions, convertedInitializer) : new LambdaBootstrap( - _settings.BootstrapHttpClient, + _httpClient, wrappedHandler, _settings.BootstrapOptions, convertedInitializer diff --git a/tests/AwsLambda.Host.UnitTests/Builder/Extensions/LambdaHttpClientServiceCollectionExtensionsTests.cs b/tests/AwsLambda.Host.UnitTests/Builder/Extensions/LambdaHttpClientServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..7eb29534 --- /dev/null +++ b/tests/AwsLambda.Host.UnitTests/Builder/Extensions/LambdaHttpClientServiceCollectionExtensionsTests.cs @@ -0,0 +1,237 @@ +using AwsLambda.Host.Builder.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace AwsLambda.Host.UnitTests.Builder.Extensions; + +[TestSubject(typeof(LambdaHttpClientServiceCollectionExtensions))] +public class LambdaHttpClientServiceCollectionExtensionsTests +{ + [Fact] + public void AddLambdaBootstrapHttpClient_WithNullServiceCollection_ThrowsArgumentNullException() + { + // Act + var act = () => ((IServiceCollection)null!).AddLambdaBootstrapHttpClient(new HttpClient()); + + // Assert + act.Should().ThrowExactly(); + } + + [Fact] + public void AddLambdaBootstrapHttpClient_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act + var act = () => serviceCollection.AddLambdaBootstrapHttpClient((HttpClient)null!); + + // Assert + act.Should().ThrowExactly(); + } + + [Fact] + public void AddLambdaBootstrapHttpClient_WithValidClient_ReturnsServiceCollection() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var httpClient = new HttpClient(); + + // Act + var result = serviceCollection.AddLambdaBootstrapHttpClient(httpClient); + + // Assert + result.Should().BeSameAs(serviceCollection); + } + + [Fact] + public void AddLambdaBootstrapHttpClient_WithValidClient_RegistersKeyedSingleton() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var httpClient = new HttpClient(); + + // Act + serviceCollection.AddLambdaBootstrapHttpClient(httpClient); + var provider = serviceCollection.BuildServiceProvider(); + + // Assert + var registeredClient = provider.GetKeyedService( + typeof(ILambdaBootstrapOrchestrator) + ); + registeredClient.Should().BeSameAs(httpClient); + } + + [Fact] + public void AddLambdaBootstrapHttpClient_WithNullFactory_ThrowsArgumentNullException() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act + var act = () => serviceCollection.AddLambdaBootstrapHttpClient(null!); + + // Assert + act.Should().ThrowExactly(); + } + + [Fact] + public void AddLambdaBootstrapHttpClient_WithValidFactory_ReturnsServiceCollection() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act + var result = serviceCollection.AddLambdaBootstrapHttpClient((sp, key) => new HttpClient()); + + // Assert + result.Should().BeSameAs(serviceCollection); + } + + [Fact] + public void AddLambdaBootstrapHttpClient_WithValidFactory_RegistersKeyedSingleton() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var httpClient = new HttpClient(); + + // Act + serviceCollection.AddLambdaBootstrapHttpClient((sp, key) => httpClient); + var provider = serviceCollection.BuildServiceProvider(); + + // Assert + var registeredClient = provider.GetKeyedService( + typeof(ILambdaBootstrapOrchestrator) + ); + registeredClient.Should().BeSameAs(httpClient); + } + + [Fact] + public void TryAddLambdaBootstrapHttpClient_WithNullServiceCollection_ThrowsArgumentNullException() + { + // Act + var act = () => + ((IServiceCollection)null!).TryAddLambdaBootstrapHttpClient(new HttpClient()); + + // Assert + act.Should().ThrowExactly(); + } + + [Fact] + public void TryAddLambdaBootstrapHttpClient_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act + var act = () => serviceCollection.TryAddLambdaBootstrapHttpClient((HttpClient)null!); + + // Assert + act.Should().ThrowExactly(); + } + + [Fact] + public void TryAddLambdaBootstrapHttpClient_WithValidClient_RegistersKeyedSingleton() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var httpClient = new HttpClient(); + + // Act + serviceCollection.TryAddLambdaBootstrapHttpClient(httpClient); + var provider = serviceCollection.BuildServiceProvider(); + + // Assert + var registeredClient = provider.GetKeyedService( + typeof(ILambdaBootstrapOrchestrator) + ); + registeredClient.Should().BeSameAs(httpClient); + } + + [Fact] + public void TryAddLambdaBootstrapHttpClient_WhenAlreadyRegistered_DoesNotOverwrite() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var firstClient = new HttpClient(); + var secondClient = new HttpClient(); + + // Act + serviceCollection.TryAddLambdaBootstrapHttpClient(firstClient); + serviceCollection.TryAddLambdaBootstrapHttpClient(secondClient); + var provider = serviceCollection.BuildServiceProvider(); + + // Assert + var registeredClient = provider.GetKeyedService( + typeof(ILambdaBootstrapOrchestrator) + ); + registeredClient.Should().BeSameAs(firstClient); + } + + [Fact] + public void TryAddLambdaBootstrapHttpClient_WithNullFactory_ThrowsArgumentNullException() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act + var act = () => serviceCollection.TryAddLambdaBootstrapHttpClient(null!); + + // Assert + act.Should().ThrowExactly(); + } + + [Fact] + public void TryAddLambdaBootstrapHttpClient_WithValidFactory_RegistersKeyedSingleton() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var httpClient = new HttpClient(); + + // Act + serviceCollection.TryAddLambdaBootstrapHttpClient((sp, key) => httpClient); + var provider = serviceCollection.BuildServiceProvider(); + + // Assert + var registeredClient = provider.GetKeyedService( + typeof(ILambdaBootstrapOrchestrator) + ); + registeredClient.Should().BeSameAs(httpClient); + } + + [Fact] + public void TryAddLambdaBootstrapHttpClient_WithFactory_WhenAlreadyRegistered_DoesNotOverwrite() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var firstClient = new HttpClient(); + var secondClient = new HttpClient(); + + // Act + serviceCollection.TryAddLambdaBootstrapHttpClient((sp, key) => firstClient); + serviceCollection.TryAddLambdaBootstrapHttpClient((sp, key) => secondClient); + var provider = serviceCollection.BuildServiceProvider(); + + // Assert + var registeredClient = provider.GetKeyedService( + typeof(ILambdaBootstrapOrchestrator) + ); + registeredClient.Should().BeSameAs(firstClient); + } + + [Fact] + public void AddLambdaBootstrapHttpClient_EnablesMethodChaining() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var firstClient = new HttpClient(); + var secondClient = new HttpClient(); + + // Act + var result = serviceCollection + .AddLambdaBootstrapHttpClient(firstClient) + .AddLambdaBootstrapHttpClient((sp, key) => secondClient); + + // Assert + result.Should().BeSameAs(serviceCollection); + } +}