diff --git a/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs b/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs index 5cfd8cc..8ed085b 100644 --- a/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs +++ b/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Reliable.HttpClient.Caching.Abstractions; @@ -19,39 +20,41 @@ public static class HttpClientWithCacheExtensions /// Cache options including default headers and settings (optional) /// Service collection for method chaining public static IServiceCollection AddHttpClientWithCache( - this IServiceCollection services, - string? httpClientName = null, - HttpCacheOptions? cacheOptions = null) + this IServiceCollection services, + string? httpClientName = null, + HttpCacheOptions? cacheOptions = null) { // Register dependencies services.AddMemoryCache(); services.AddSingleton(); - // Register the universal HTTP client with cache - services.AddSingleton(serviceProvider => - { - IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); - System.Net.Http.HttpClient httpClient = httpClientName is null or "" - ? httpClientFactory.CreateClient() - : httpClientFactory.CreateClient(httpClientName); - - IMemoryCache cache = serviceProvider.GetRequiredService(); - IHttpResponseHandler responseHandler = serviceProvider.GetRequiredService(); // Use universal handler - ISimpleCacheKeyGenerator cacheKeyGenerator = serviceProvider.GetRequiredService(); - ILogger? logger = serviceProvider.GetService>(); - - return new HttpClientWithCache( - httpClient, - cache, - responseHandler, - cacheOptions, - cacheKeyGenerator, - logger); - }); + // Register the universal HTTP client with cache as scoped to avoid captive dependency + services.AddScoped(sp => CreateHttpClientWithCache(sp, httpClientName, cacheOptions)); + services.AddScoped(sp => (HttpClientWithCache)sp.GetRequiredService()); return services; } + /// + /// Adds universal HTTP client with caching to the service collection + /// + /// Service collection + /// HTTP client name (optional) + /// Action to configure cache options + /// which then will be registered as named after + /// Service collection for method chaining + public static IServiceCollection AddHttpClientWithCache( + this IServiceCollection services, + string? httpClientName, + Action configureCacheOptions) + { + var options = new HttpCacheOptions(); + configureCacheOptions(options); + services.Configure(httpClientName, configureCacheOptions); + + return services.AddHttpClientWithCache(httpClientName, options); + } + /// /// Adds universal HTTP client with caching and resilience to the service collection /// @@ -97,4 +100,86 @@ public static IServiceCollection AddResilientHttpClientWithCache( // Add universal HTTP client with cache return services.AddHttpClientWithCache(httpClientName, cacheOptions); } + + /// + /// Adds universal HTTP client with caching and resilience to the service collection + /// + /// Service collection + /// HTTP client name + /// Action to configure resilience options + /// Action to configure cache options + /// Service collection for method chaining + public static IServiceCollection AddResilientHttpClientWithCache( + this IServiceCollection services, + string httpClientName, + Action configureCacheOptions, + Action? configureResilience = null) + { + var cacheOptions = new HttpCacheOptions(); + configureCacheOptions(cacheOptions); + services.Configure(httpClientName, configureCacheOptions); + + return services.AddResilientHttpClientWithCache(httpClientName, configureResilience, cacheOptions); + } + + /// + /// Adds universal HTTP client with caching using preset resilience configuration + /// + /// Service collection + /// HTTP client name + /// Predefined resilience preset + /// Optional action to customize preset options + /// Action to configure cache options + /// Service collection for method chaining + public static IServiceCollection AddResilientHttpClientWithCache( + this IServiceCollection services, + string httpClientName, + HttpClientOptions preset, + Action configureCacheOptions, + Action? customizeOptions = null) + { + var cacheOptions = new HttpCacheOptions(); + configureCacheOptions(cacheOptions); + services.Configure(httpClientName, configureCacheOptions); + + return services.AddResilientHttpClientWithCache(httpClientName, preset, customizeOptions, cacheOptions); + } + + /// + /// Creates an instance of using the provided service provider and configuration. + /// + /// The service provider used to resolve required dependencies. + /// The name of the HTTP client to retrieve from the . + /// Cache options including default headers and settings (optional) + /// If null or empty, a default client is created. + /// A configured instance of . + /// Thrown if a required service (e.g., , , + /// , or ) is not registered in the service provider. + private static HttpClientWithCache CreateHttpClientWithCache( + IServiceProvider serviceProvider, + string? httpClientName, + HttpCacheOptions? cacheOptions) + { + IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); + System.Net.Http.HttpClient httpClient = httpClientName is null or "" + ? httpClientFactory.CreateClient() + : httpClientFactory.CreateClient(httpClientName); + + IMemoryCache cache = serviceProvider.GetRequiredService(); + IHttpResponseHandler responseHandler = serviceProvider.GetRequiredService(); + HttpCacheOptions? cacheOptionsToInject = cacheOptions is null + ? serviceProvider.GetService>()?.Get(httpClientName) + : cacheOptions; + IOptionsSnapshot? cacheOptionsSnapshot = serviceProvider.GetService>(); + ISimpleCacheKeyGenerator cacheKeyGenerator = serviceProvider.GetRequiredService(); + ILogger? logger = serviceProvider.GetService>(); + + return new HttpClientWithCache( + httpClient, + cache, + responseHandler, + cacheOptionsToInject, + cacheKeyGenerator, + logger); + } } diff --git a/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs new file mode 100644 index 0000000..a28abb4 --- /dev/null +++ b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs @@ -0,0 +1,163 @@ +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +using Reliable.HttpClient.Caching.Abstractions; +using Reliable.HttpClient.Caching.Extensions; + +namespace Reliable.HttpClient.Caching.Tests; + +public class HttpClientWithCacheExtensionsTests +{ + [Fact] + public void AddHttpClientWithCache_RegistersAllRequiredServices() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHttpClient(); + services.AddSingleton(); + + // Act + services.AddHttpClientWithCache("CachedClient"); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Assert + serviceProvider.GetService().Should().NotBeNull().And.BeOfType(); + serviceProvider.GetService().Should().NotBeNull(); + serviceProvider.GetService().Should().NotBeNull(); + serviceProvider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AddHttpClientWithCache_WithNamedClient_RegistersNamedHttpClient() + { + // Arrange + const string clientName = "CachedClient"; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHttpClient(); + services.AddSingleton(); + + // Act + services.AddHttpClientWithCache(clientName); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Assert + IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); + System.Net.Http.HttpClient httpClient = httpClientFactory.CreateClient(clientName); + httpClient.Should().NotBeNull(); + + IHttpClientWithCache httpClientWithCache = serviceProvider.GetRequiredService(); + httpClientWithCache.Should().NotBeNull().And.BeOfType(); + } + + [Fact] + public void AddHttpClientWithCache_WithCacheOptions_AppliesCacheOptions() + { + // Arrange + const string clientName = "CachedClient"; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHttpClient(); + services.AddSingleton(); + + // Act + services.AddHttpClientWithCache(clientName, options => options.DefaultExpiry = TimeSpan.FromMinutes(10)); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Assert + var httpClientWithCache = serviceProvider.GetRequiredService() as HttpClientWithCache; + httpClientWithCache.Should().NotBeNull(); + + // Ensure that HttpCacheOptions configured to MediumTerm cache preset + // Using IOptionsSnapshot to get settings for particular named HTTP client + IOptionsSnapshot optionsSnapshot = serviceProvider.GetRequiredService>(); + HttpCacheOptions registeredOptions = optionsSnapshot.Get(clientName); + + registeredOptions.DefaultExpiry.Should().Be(TimeSpan.FromMinutes(10)); + } + + [Fact] + public void AddResilientHttpClientWithCache_RegistersAllRequiredServices() + { + // Arrange + const string clientName = "ResilientClient"; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHttpClient(); + services.AddSingleton(); + + // Act + services.AddResilientHttpClientWithCache(clientName); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Assert + IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); + System.Net.Http.HttpClient httpClient = httpClientFactory.CreateClient(clientName); + httpClient.Should().NotBeNull(); + + serviceProvider.GetService().Should().NotBeNull().And.BeOfType(); + serviceProvider.GetService().Should().NotBeNull(); + serviceProvider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AddHttpClientWithCache_MultipleCalls_DoesNotThrow() + { + // Arrange + const string clientName = "CachedClient"; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHttpClient(); + services.AddSingleton(); + + // Act & Assert + Func act = () => services + .AddHttpClientWithCache(clientName) + .AddHttpClientWithCache(clientName); + + act.Should().NotThrow(); + } + + [Fact] + public void AddResilientHttpClientWithCache_MultipleCalls_DoesNotThrow() + { + // Arrange + const string clientName = "ResilientClient"; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(); + + // Act & Assert + Func act = () => services + .AddResilientHttpClientWithCache(clientName) + .AddResilientHttpClientWithCache(clientName); + + act.Should().NotThrow(); + } + + [Fact] + public void AddHttpClientWithCache_CanResolveConcreteType_SameInstance() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHttpClient(); + services.AddSingleton(); + services.AddHttpClientWithCache("TestClient"); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + HttpClientWithCache? concrete = serviceProvider.GetService(); + IHttpClientWithCache? abstraction = serviceProvider.GetService(); + + // Assert - This was the core issue from #5 + concrete.Should().NotBeNull(); + abstraction.Should().NotBeNull(); + ReferenceEquals(concrete, abstraction).Should().BeTrue(); + } +}