Skip to content

Commit 026c66d

Browse files
fix: HttpClientWithCache could not be resolved from DI (#6)
* fix: HttpClientWithCache DI resolution * chore: Review feedback. Backward compatibility tweaks and tests - implement overloads to support both Action<T> cache options configuration and direct instance injection approach for backward compatibility; - add missing unit test of proper concrete and abstractions DI resolution
1 parent edf3312 commit 026c66d

2 files changed

Lines changed: 272 additions & 24 deletions

File tree

src/Reliable.HttpClient.Caching/Extensions/HttpClientWithCacheExtensions.cs

Lines changed: 109 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using Microsoft.Extensions.Caching.Memory;
33
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Options;
45

56
using Reliable.HttpClient.Caching.Abstractions;
67

@@ -19,39 +20,41 @@ public static class HttpClientWithCacheExtensions
1920
/// <param name="cacheOptions">Cache options including default headers and settings (optional)</param>
2021
/// <returns>Service collection for method chaining</returns>
2122
public static IServiceCollection AddHttpClientWithCache(
22-
this IServiceCollection services,
23-
string? httpClientName = null,
24-
HttpCacheOptions? cacheOptions = null)
23+
this IServiceCollection services,
24+
string? httpClientName = null,
25+
HttpCacheOptions? cacheOptions = null)
2526
{
2627
// Register dependencies
2728
services.AddMemoryCache();
2829
services.AddSingleton<ISimpleCacheKeyGenerator, DefaultSimpleCacheKeyGenerator>();
2930

30-
// Register the universal HTTP client with cache
31-
services.AddSingleton<IHttpClientWithCache>(serviceProvider =>
32-
{
33-
IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
34-
System.Net.Http.HttpClient httpClient = httpClientName is null or ""
35-
? httpClientFactory.CreateClient()
36-
: httpClientFactory.CreateClient(httpClientName);
37-
38-
IMemoryCache cache = serviceProvider.GetRequiredService<IMemoryCache>();
39-
IHttpResponseHandler responseHandler = serviceProvider.GetRequiredService<IHttpResponseHandler>(); // Use universal handler
40-
ISimpleCacheKeyGenerator cacheKeyGenerator = serviceProvider.GetRequiredService<ISimpleCacheKeyGenerator>();
41-
ILogger<HttpClientWithCache>? logger = serviceProvider.GetService<ILogger<HttpClientWithCache>>();
42-
43-
return new HttpClientWithCache(
44-
httpClient,
45-
cache,
46-
responseHandler,
47-
cacheOptions,
48-
cacheKeyGenerator,
49-
logger);
50-
});
31+
// Register the universal HTTP client with cache as scoped to avoid captive dependency
32+
services.AddScoped<IHttpClientWithCache>(sp => CreateHttpClientWithCache(sp, httpClientName, cacheOptions));
33+
services.AddScoped(sp => (HttpClientWithCache)sp.GetRequiredService<IHttpClientWithCache>());
5134

5235
return services;
5336
}
5437

38+
/// <summary>
39+
/// Adds universal HTTP client with caching to the service collection
40+
/// </summary>
41+
/// <param name="services">Service collection</param>
42+
/// <param name="httpClientName">HTTP client name (optional)</param>
43+
/// <param name="configureCacheOptions">Action to configure cache options
44+
/// which then will be registered as <see cref="IOptions{TOptions}"/> named after <paramref name="httpClientName"/></param>
45+
/// <returns>Service collection for method chaining</returns>
46+
public static IServiceCollection AddHttpClientWithCache(
47+
this IServiceCollection services,
48+
string? httpClientName,
49+
Action<HttpCacheOptions> configureCacheOptions)
50+
{
51+
var options = new HttpCacheOptions();
52+
configureCacheOptions(options);
53+
services.Configure(httpClientName, configureCacheOptions);
54+
55+
return services.AddHttpClientWithCache(httpClientName, options);
56+
}
57+
5558
/// <summary>
5659
/// Adds universal HTTP client with caching and resilience to the service collection
5760
/// </summary>
@@ -97,4 +100,86 @@ public static IServiceCollection AddResilientHttpClientWithCache(
97100
// Add universal HTTP client with cache
98101
return services.AddHttpClientWithCache(httpClientName, cacheOptions);
99102
}
103+
104+
/// <summary>
105+
/// Adds universal HTTP client with caching and resilience to the service collection
106+
/// </summary>
107+
/// <param name="services">Service collection</param>
108+
/// <param name="httpClientName">HTTP client name</param>
109+
/// <param name="configureResilience">Action to configure resilience options</param>
110+
/// <param name="configureCacheOptions">Action to configure cache options</param>
111+
/// <returns>Service collection for method chaining</returns>
112+
public static IServiceCollection AddResilientHttpClientWithCache(
113+
this IServiceCollection services,
114+
string httpClientName,
115+
Action<HttpCacheOptions> configureCacheOptions,
116+
Action<HttpClientOptions>? configureResilience = null)
117+
{
118+
var cacheOptions = new HttpCacheOptions();
119+
configureCacheOptions(cacheOptions);
120+
services.Configure(httpClientName, configureCacheOptions);
121+
122+
return services.AddResilientHttpClientWithCache(httpClientName, configureResilience, cacheOptions);
123+
}
124+
125+
/// <summary>
126+
/// Adds universal HTTP client with caching using preset resilience configuration
127+
/// </summary>
128+
/// <param name="services">Service collection</param>
129+
/// <param name="httpClientName">HTTP client name</param>
130+
/// <param name="preset">Predefined resilience preset</param>
131+
/// <param name="customizeOptions">Optional action to customize preset options</param>
132+
/// <param name="configureCacheOptions">Action to configure cache options</param>
133+
/// <returns>Service collection for method chaining</returns>
134+
public static IServiceCollection AddResilientHttpClientWithCache(
135+
this IServiceCollection services,
136+
string httpClientName,
137+
HttpClientOptions preset,
138+
Action<HttpCacheOptions> configureCacheOptions,
139+
Action<HttpClientOptions>? customizeOptions = null)
140+
{
141+
var cacheOptions = new HttpCacheOptions();
142+
configureCacheOptions(cacheOptions);
143+
services.Configure(httpClientName, configureCacheOptions);
144+
145+
return services.AddResilientHttpClientWithCache(httpClientName, preset, customizeOptions, cacheOptions);
146+
}
147+
148+
/// <summary>
149+
/// Creates an instance of <see cref="HttpClientWithCache"/> using the provided service provider and configuration.
150+
/// </summary>
151+
/// <param name="serviceProvider">The service provider used to resolve required dependencies.</param>
152+
/// <param name="httpClientName">The name of the HTTP client to retrieve from the <see cref="IHttpClientFactory"/>.
153+
/// <param name="cacheOptions">Cache options including default headers and settings (optional)</param>
154+
/// If null or empty, a default client is created.</param>
155+
/// <returns>A configured instance of <see cref="HttpClientWithCache"/>.</returns>
156+
/// <exception cref="InvalidOperationException">Thrown if a required service (e.g., <see cref="IHttpClientFactory"/>, <see cref="IMemoryCache"/>,
157+
/// <see cref="IHttpResponseHandler"/>, or <see cref="ISimpleCacheKeyGenerator"/>) is not registered in the service provider.</exception>
158+
private static HttpClientWithCache CreateHttpClientWithCache(
159+
IServiceProvider serviceProvider,
160+
string? httpClientName,
161+
HttpCacheOptions? cacheOptions)
162+
{
163+
IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
164+
System.Net.Http.HttpClient httpClient = httpClientName is null or ""
165+
? httpClientFactory.CreateClient()
166+
: httpClientFactory.CreateClient(httpClientName);
167+
168+
IMemoryCache cache = serviceProvider.GetRequiredService<IMemoryCache>();
169+
IHttpResponseHandler responseHandler = serviceProvider.GetRequiredService<IHttpResponseHandler>();
170+
HttpCacheOptions? cacheOptionsToInject = cacheOptions is null
171+
? serviceProvider.GetService<IOptionsSnapshot<HttpCacheOptions>>()?.Get(httpClientName)
172+
: cacheOptions;
173+
IOptionsSnapshot<HttpCacheOptions>? cacheOptionsSnapshot = serviceProvider.GetService<IOptionsSnapshot<HttpCacheOptions>>();
174+
ISimpleCacheKeyGenerator cacheKeyGenerator = serviceProvider.GetRequiredService<ISimpleCacheKeyGenerator>();
175+
ILogger<HttpClientWithCache>? logger = serviceProvider.GetService<ILogger<HttpClientWithCache>>();
176+
177+
return new HttpClientWithCache(
178+
httpClient,
179+
cache,
180+
responseHandler,
181+
cacheOptionsToInject,
182+
cacheKeyGenerator,
183+
logger);
184+
}
100185
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
using FluentAssertions;
2+
using Microsoft.Extensions.Caching.Memory;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Options;
5+
using Xunit;
6+
7+
using Reliable.HttpClient.Caching.Abstractions;
8+
using Reliable.HttpClient.Caching.Extensions;
9+
10+
namespace Reliable.HttpClient.Caching.Tests;
11+
12+
public class HttpClientWithCacheExtensionsTests
13+
{
14+
[Fact]
15+
public void AddHttpClientWithCache_RegistersAllRequiredServices()
16+
{
17+
// Arrange
18+
var services = new ServiceCollection();
19+
services.AddLogging();
20+
services.AddHttpClient();
21+
services.AddSingleton<IHttpResponseHandler, DefaultHttpResponseHandler>();
22+
23+
// Act
24+
services.AddHttpClientWithCache("CachedClient");
25+
ServiceProvider serviceProvider = services.BuildServiceProvider();
26+
27+
// Assert
28+
serviceProvider.GetService<IHttpClientWithCache>().Should().NotBeNull().And.BeOfType<HttpClientWithCache>();
29+
serviceProvider.GetService<IMemoryCache>().Should().NotBeNull();
30+
serviceProvider.GetService<ISimpleCacheKeyGenerator>().Should().NotBeNull();
31+
serviceProvider.GetService<System.Net.Http.HttpClient>().Should().NotBeNull();
32+
}
33+
34+
[Fact]
35+
public void AddHttpClientWithCache_WithNamedClient_RegistersNamedHttpClient()
36+
{
37+
// Arrange
38+
const string clientName = "CachedClient";
39+
var services = new ServiceCollection();
40+
services.AddLogging();
41+
services.AddHttpClient();
42+
services.AddSingleton<IHttpResponseHandler, DefaultHttpResponseHandler>();
43+
44+
// Act
45+
services.AddHttpClientWithCache(clientName);
46+
ServiceProvider serviceProvider = services.BuildServiceProvider();
47+
48+
// Assert
49+
IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
50+
System.Net.Http.HttpClient httpClient = httpClientFactory.CreateClient(clientName);
51+
httpClient.Should().NotBeNull();
52+
53+
IHttpClientWithCache httpClientWithCache = serviceProvider.GetRequiredService<IHttpClientWithCache>();
54+
httpClientWithCache.Should().NotBeNull().And.BeOfType<HttpClientWithCache>();
55+
}
56+
57+
[Fact]
58+
public void AddHttpClientWithCache_WithCacheOptions_AppliesCacheOptions()
59+
{
60+
// Arrange
61+
const string clientName = "CachedClient";
62+
var services = new ServiceCollection();
63+
services.AddLogging();
64+
services.AddHttpClient();
65+
services.AddSingleton<IHttpResponseHandler, DefaultHttpResponseHandler>();
66+
67+
// Act
68+
services.AddHttpClientWithCache(clientName, options => options.DefaultExpiry = TimeSpan.FromMinutes(10));
69+
ServiceProvider serviceProvider = services.BuildServiceProvider();
70+
71+
// Assert
72+
var httpClientWithCache = serviceProvider.GetRequiredService<IHttpClientWithCache>() as HttpClientWithCache;
73+
httpClientWithCache.Should().NotBeNull();
74+
75+
// Ensure that HttpCacheOptions configured to MediumTerm cache preset
76+
// Using IOptionsSnapshot to get settings for particular named HTTP client
77+
IOptionsSnapshot<HttpCacheOptions> optionsSnapshot = serviceProvider.GetRequiredService<IOptionsSnapshot<HttpCacheOptions>>();
78+
HttpCacheOptions registeredOptions = optionsSnapshot.Get(clientName);
79+
80+
registeredOptions.DefaultExpiry.Should().Be(TimeSpan.FromMinutes(10));
81+
}
82+
83+
[Fact]
84+
public void AddResilientHttpClientWithCache_RegistersAllRequiredServices()
85+
{
86+
// Arrange
87+
const string clientName = "ResilientClient";
88+
var services = new ServiceCollection();
89+
services.AddLogging();
90+
services.AddHttpClient();
91+
services.AddSingleton<IHttpResponseHandler, DefaultHttpResponseHandler>();
92+
93+
// Act
94+
services.AddResilientHttpClientWithCache(clientName);
95+
ServiceProvider serviceProvider = services.BuildServiceProvider();
96+
97+
// Assert
98+
IHttpClientFactory httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
99+
System.Net.Http.HttpClient httpClient = httpClientFactory.CreateClient(clientName);
100+
httpClient.Should().NotBeNull();
101+
102+
serviceProvider.GetService<IHttpClientWithCache>().Should().NotBeNull().And.BeOfType<HttpClientWithCache>();
103+
serviceProvider.GetService<IMemoryCache>().Should().NotBeNull();
104+
serviceProvider.GetService<ISimpleCacheKeyGenerator>().Should().NotBeNull();
105+
}
106+
107+
[Fact]
108+
public void AddHttpClientWithCache_MultipleCalls_DoesNotThrow()
109+
{
110+
// Arrange
111+
const string clientName = "CachedClient";
112+
var services = new ServiceCollection();
113+
services.AddLogging();
114+
services.AddHttpClient();
115+
services.AddSingleton<IHttpResponseHandler, DefaultHttpResponseHandler>();
116+
117+
// Act & Assert
118+
Func<IServiceCollection> act = () => services
119+
.AddHttpClientWithCache(clientName)
120+
.AddHttpClientWithCache(clientName);
121+
122+
act.Should().NotThrow();
123+
}
124+
125+
[Fact]
126+
public void AddResilientHttpClientWithCache_MultipleCalls_DoesNotThrow()
127+
{
128+
// Arrange
129+
const string clientName = "ResilientClient";
130+
var services = new ServiceCollection();
131+
services.AddLogging();
132+
services.AddSingleton<IHttpResponseHandler, DefaultHttpResponseHandler>();
133+
134+
// Act & Assert
135+
Func<IServiceCollection> act = () => services
136+
.AddResilientHttpClientWithCache(clientName)
137+
.AddResilientHttpClientWithCache(clientName);
138+
139+
act.Should().NotThrow();
140+
}
141+
142+
[Fact]
143+
public void AddHttpClientWithCache_CanResolveConcreteType_SameInstance()
144+
{
145+
// Arrange
146+
var services = new ServiceCollection();
147+
services.AddLogging();
148+
services.AddHttpClient();
149+
services.AddSingleton<IHttpResponseHandler, DefaultHttpResponseHandler>();
150+
services.AddHttpClientWithCache("TestClient");
151+
152+
ServiceProvider serviceProvider = services.BuildServiceProvider();
153+
154+
// Act
155+
HttpClientWithCache? concrete = serviceProvider.GetService<HttpClientWithCache>();
156+
IHttpClientWithCache? abstraction = serviceProvider.GetService<IHttpClientWithCache>();
157+
158+
// Assert - This was the core issue from #5
159+
concrete.Should().NotBeNull();
160+
abstraction.Should().NotBeNull();
161+
ReferenceEquals(concrete, abstraction).Should().BeTrue();
162+
}
163+
}

0 commit comments

Comments
 (0)