From 84c8bd130d2689fa6d005f3ea7034e2ed96e6058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=A2=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=BD?= Date: Wed, 24 Sep 2025 08:33:32 +0300 Subject: [PATCH 1/2] fix: HttpClientWithCache DI resolution --- .../HttpClientWithCacheExtensions.cs | 75 ++++++---- .../HttpClientWithCacheExtensionsTests.cs | 141 ++++++++++++++++++ 2 files changed, 189 insertions(+), 27 deletions(-) create mode 100644 tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs diff --git a/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs b/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs index 5cfd8cc..4f56c33 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; @@ -16,38 +17,25 @@ public static class HttpClientWithCacheExtensions /// /// Service collection /// HTTP client name (optional) - /// Cache options including default headers and settings (optional) + /// Action to configure cache options /// Service collection for method chaining public static IServiceCollection AddHttpClientWithCache( this IServiceCollection services, string? httpClientName = null, - HttpCacheOptions? cacheOptions = null) + Action? configureCacheOptions = null) { // Register dependencies services.AddMemoryCache(); services.AddSingleton(); - // Register the universal HTTP client with cache - services.AddSingleton(serviceProvider => + if (configureCacheOptions is not null) { - IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); - System.Net.Http.HttpClient httpClient = httpClientName is null or "" - ? httpClientFactory.CreateClient() - : httpClientFactory.CreateClient(httpClientName); + services.Configure(httpClientName, configureCacheOptions); + } - 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)); + services.AddScoped(sp => (HttpClientWithCache)sp.GetRequiredService()); return services; } @@ -58,20 +46,20 @@ public static IServiceCollection AddHttpClientWithCache( /// Service collection /// HTTP client name /// Action to configure resilience options - /// Cache options including default headers and settings (optional) + /// Action to configure cache options /// Service collection for method chaining public static IServiceCollection AddResilientHttpClientWithCache( this IServiceCollection services, string httpClientName, Action? configureResilience = null, - HttpCacheOptions? cacheOptions = null) + Action? configureCacheOptions = null) { // Add HTTP client with resilience services.AddHttpClient(httpClientName) .AddResilience(configureResilience); // Add universal HTTP client with cache - return services.AddHttpClientWithCache(httpClientName, cacheOptions); + return services.AddHttpClientWithCache(httpClientName, configureCacheOptions); } /// @@ -81,20 +69,53 @@ public static IServiceCollection AddResilientHttpClientWithCache( /// HTTP client name /// Predefined resilience preset /// Optional action to customize preset options - /// Cache options including default headers and settings (optional) + /// Action to configure cache options /// Service collection for method chaining public static IServiceCollection AddResilientHttpClientWithCache( this IServiceCollection services, string httpClientName, HttpClientOptions preset, Action? customizeOptions = null, - HttpCacheOptions? cacheOptions = null) + Action? configureCacheOptions = null) { // Add HTTP client with resilience preset services.AddHttpClient(httpClientName) .AddResilience(preset, customizeOptions); // Add universal HTTP client with cache - return services.AddHttpClientWithCache(httpClientName, cacheOptions); + return services.AddHttpClientWithCache(httpClientName, configureCacheOptions); + } + + /// + /// 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 . + /// 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) + { + 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(); + IOptionsSnapshot? cacheOptionsSnapshot = serviceProvider.GetService>(); + ISimpleCacheKeyGenerator cacheKeyGenerator = serviceProvider.GetRequiredService(); + ILogger? logger = serviceProvider.GetService>(); + + return new HttpClientWithCache( + httpClient, + cache, + responseHandler, + cacheOptionsSnapshot?.Get(httpClientName), + 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..09644f0 --- /dev/null +++ b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs @@ -0,0 +1,141 @@ +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(); + + // Проверяем, что HttpCacheOptions правильно настроены для MediumTerm preset + // Используем IOptionsSnapshot для получения настроек именованного клиента "KodySuCached" + 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(); + } +} From 8bfc99485f795a0710a10ea14a6a2fb0eae73747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=A2=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=BD?= Date: Wed, 24 Sep 2025 12:28:11 +0300 Subject: [PATCH 2/2] chore: Review feedback. Backward compatibility tweaks and tests - implement overloads to support both Action cache options configuration and direct instance injection approach for backward compatibility; - add missing unit test of proper concrete and abstractions DI resolution --- .../HttpClientWithCacheExtensions.cs | 100 ++++++++++++++---- .../HttpClientWithCacheExtensionsTests.cs | 26 ++++- 2 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs b/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs index 4f56c33..8ed085b 100644 --- a/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs +++ b/src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs @@ -17,49 +17,64 @@ public static class HttpClientWithCacheExtensions /// /// Service collection /// HTTP client name (optional) - /// Action to configure cache options + /// Cache options including default headers and settings (optional) /// Service collection for method chaining public static IServiceCollection AddHttpClientWithCache( - this IServiceCollection services, - string? httpClientName = null, - Action? configureCacheOptions = null) + this IServiceCollection services, + string? httpClientName = null, + HttpCacheOptions? cacheOptions = null) { // Register dependencies services.AddMemoryCache(); services.AddSingleton(); - if (configureCacheOptions is not null) - { - services.Configure(httpClientName, configureCacheOptions); - } - // Register the universal HTTP client with cache as scoped to avoid captive dependency - services.AddScoped(sp => CreateHttpClientWithCache(sp, httpClientName)); + 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 /// /// Service collection /// HTTP client name /// Action to configure resilience options - /// Action to configure cache options + /// Cache options including default headers and settings (optional) /// Service collection for method chaining public static IServiceCollection AddResilientHttpClientWithCache( this IServiceCollection services, string httpClientName, Action? configureResilience = null, - Action? configureCacheOptions = null) + HttpCacheOptions? cacheOptions = null) { // Add HTTP client with resilience services.AddHttpClient(httpClientName) .AddResilience(configureResilience); // Add universal HTTP client with cache - return services.AddHttpClientWithCache(httpClientName, configureCacheOptions); + return services.AddHttpClientWithCache(httpClientName, cacheOptions); } /// @@ -69,21 +84,65 @@ public static IServiceCollection AddResilientHttpClientWithCache( /// HTTP client name /// Predefined resilience preset /// Optional action to customize preset options - /// Action to configure cache options + /// Cache options including default headers and settings (optional) /// Service collection for method chaining public static IServiceCollection AddResilientHttpClientWithCache( this IServiceCollection services, string httpClientName, HttpClientOptions preset, Action? customizeOptions = null, - Action? configureCacheOptions = null) + HttpCacheOptions? cacheOptions = null) { // Add HTTP client with resilience preset services.AddHttpClient(httpClientName) .AddResilience(preset, customizeOptions); // Add universal HTTP client with cache - return services.AddHttpClientWithCache(httpClientName, configureCacheOptions); + 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); } /// @@ -91,13 +150,15 @@ public static IServiceCollection AddResilientHttpClientWithCache( /// /// 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) + string? httpClientName, + HttpCacheOptions? cacheOptions) { IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService(); System.Net.Http.HttpClient httpClient = httpClientName is null or "" @@ -106,6 +167,9 @@ private static HttpClientWithCache CreateHttpClientWithCache( 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>(); @@ -114,7 +178,7 @@ private static HttpClientWithCache CreateHttpClientWithCache( httpClient, cache, responseHandler, - cacheOptionsSnapshot?.Get(httpClientName), + cacheOptionsToInject, cacheKeyGenerator, logger); } diff --git a/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs index 09644f0..a28abb4 100644 --- a/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs +++ b/tests/Reliable.HttpClient.Caching.Tests/HttpClientWithCacheExtensionsTests.cs @@ -72,8 +72,8 @@ public void AddHttpClientWithCache_WithCacheOptions_AppliesCacheOptions() var httpClientWithCache = serviceProvider.GetRequiredService() as HttpClientWithCache; httpClientWithCache.Should().NotBeNull(); - // Проверяем, что HttpCacheOptions правильно настроены для MediumTerm preset - // Используем IOptionsSnapshot для получения настроек именованного клиента "KodySuCached" + // 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); @@ -138,4 +138,26 @@ public void AddResilientHttpClientWithCache_MultipleCalls_DoesNotThrow() 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(); + } }