I have written a C# wrapper library that targets .net6,.net7,.net8
. I want to add the DI support so that users can configure it by calling the services extension method AddMyApp
.
namespace Project1
{
public class FooHttpClientOptions : IBasicAuthHttpClientOptions
{
public Uri Url { get; set; }
public BasicAuthCredential Credentials { get; set; }
}
public interface IBasicAuthHttpClientOptions
{
Uri Url { get; set; }
BasicAuthCredential Credentials { get; set; }
}
public class BasicAuthCredential
{
public const string Scheme = "x-api-key";
public string ApiKey { get; set; } = string.Empty;
}
public static class MyAppExtensions
{
public static IServiceCollection AddMyApp<THttpClient, THttpClientOptions>(
this IServiceCollection services,
Action<THttpClientOptions> configure) where THttpClient : class where THttpClientOptions : class, IBasicAuthHttpClientOptions
{
services
.AddOptions<THttpClientOptions>()
.Configure(configure)
.Validate(options => string.IsNullOrEmpty(options.Credentials.ApiKey)
|| string.IsNullOrWhiteSpace(options.Credentials.ApiKey), MyAppExceptionMessages.InvalidApiKeyMessage)
.ValidateOnStart();
services.AddTransient<BasicAuthenticationHandler<THttpClientOptions>>();
services.AddHttpClient<THttpClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<THttpClientOptions>>().Value;
client.BaseAddress = options.Url;
})
.AddHttpMessageHandler<BasicAuthenticationHandler<THttpClientOptions>>();
services.AddTransient<ICustomHttpClient, CustomHttpClient>();
services.AddTransient<ICarsService, CarsService>();
return services;
}
}
public class BasicAuthenticationHandler<TOptions> : DelegatingHandler
where TOptions : class, IBasicAuthHttpClientOptions
{
private readonly TOptions _options;
public BasicAuthenticationHandler(IOptions<TOptions> options)
{
_options = options.Value;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue(BasicAuthCredential.Scheme, _options.Credentials.ApiKey);
return await base.SendAsync(request, cancellationToken);
}
}
public class MyAppExceptionMessages
{
public const string InvalidApiKeyMessage = "Your API key is invalid.";
}
}
Here is the CustomHttpClient
class. I have applied the breakpoint in its constructor but the httpClient
object does not contain BaseUrl
and the request headers are empty as well.
public class CustomHttpClient : ICustomHttpClient
{
private readonly HttpClient _httpClient;
public CustomHttpClient (HttpClient httpClient)
{
_httpClient = httpClient;
}
}
Here's how I am calling the extension method:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<IBasicAuthHttpClientOptions, FooHttpClientOptions>();
builder.Services.AddMyApp<CustomHttpClient, FooHttpClientOptions>(option =>
{
option.Url = new Uri("xxxxxxxxxxxxxx");
option.Credentials = new BasicAuthCredential
{
ApiKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
};
});
var app = builder.Build();
Why aren't the provided Url
and ApiKey
values reaching the httpClient
object inside the CustomHttpClient
?
You were pretty close to getting it right, yet two things were holding you back:
HttpClient
for service using implementation typeWhen this line gets executed:
services.AddHttpClient<THttpClient>()
a typed client is registered within the service collection under the name of the provided type.
Additionally, when you hover your mouse over AddHttpClient()
one of the sentences says:
(...)
THttpClient
instances constructed with the appropriateHttpClient
can be retrieved fromIServiceProvider.GetService(Type)
(and related methods) by providingTHttpClient
as the service type.
So, the conclusion we can draw is that your custom HttpClient
will be bound with the implementation type because that's the type you are passing when calling:
services.AddMyApp<CustomHttpClient, FooHttpClientOptions>(x => { });
But, your CustomHttpClient
implements a ICustomHttpClient
interface and that's the type you are using when resolving it (which I assume). But no HttpClient
is bound with ICustomHttpClient
, only with CustomHttpClient
. You can fix that by adding a third type parameter for the interface and pass it down to AddHttpClient()
extension method:
public static IServiceCollection AddMyApp<TService, THttpClient, THttpClientOptions>(
this IServiceCollection services,
Action<THttpClientOptions> configure)
where TService : class
where THttpClient : class, TService
where THttpClientOptions : class, IBasicAuthHttpClientOptions
{
services.AddHttpClient<TService, THttpClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<THttpClientOptions>>().Value;
client.BaseAddress = options.Url;
});
return services;
}
CustomHttpClient
Now, at this point this might work, but it's highly probable you'll fall into a second pitfall. In the official documentation about IHttpClientFactory
there is a registration example:
builder.Services.AddHttpClient<ICatalogService, CatalogService>();
builder.Services.AddHttpClient<IBasketService, BasketService>();
builder.Services.AddHttpClient<IOrderingService, OrderingService>();
with an important note:
The typed client is registered as transient with DI container. In the preceding code, AddHttpClient() registers CatalogService, BasketService, OrderingService as transient services so they can be injected and consumed directly without any need for additional registrations.
That means that by calling services.AddHttpClient<TService, THttpClient>()
the extension method already registers TService
with implementation being THttpClient
for you. So, if you call your own custom extension method:
services.AddMyApp<CustomHttpClient, FooHttpClientOptions>(x => { });
you'll end up with two registrations for CustomHttpClient
(or ICustomHttpClient
if you use the modified version from above). Below is a view of service collection from debugger:
Registering the same service is allowed, but when resolving a single service, the last registration will be used and the last registration is not bound with HttpClient
. To fix it, remove this line:
services.AddTransient<ICustomHttpClient, CustomHttpClient>();
I said before this might work, because if you use a different client, it won't happen because the type passed will be different from CustomHttpClient
, but I suspect that you were testing that extension with CustomHttpClient
like this:
services.AddMyApp<CustomHttpClient, FooHttpClientOptions>(x => { });
Here's the final code of extension method:
public static IServiceCollection AddMyApp<TService, THttpClient, THttpClientOptions>(
this IServiceCollection services,
Action<THttpClientOptions> configure)
where TService : class
where THttpClient : class, TService
where THttpClientOptions : class, IBasicAuthHttpClientOptions
{
services
.AddOptions<THttpClientOptions>()
.Configure(configure)
.Validate(options => !string.IsNullOrEmpty(options.Credentials.ApiKey)
&& !string.IsNullOrWhiteSpace(options.Credentials.ApiKey), MyAppExceptionMessages.InvalidApiKeyMessage)
.ValidateOnStart();
services.AddTransient<BasicAuthenticationHandler<THttpClientOptions>>();
services.AddHttpClient<TService, THttpClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<THttpClientOptions>>().Value;
client.BaseAddress = options.Url;
})
.AddHttpMessageHandler<BasicAuthenticationHandler<THttpClientOptions>>();
services.AddTransient<ICarsService, CarsService>();
return services;
}
PS I inverted API key validation logic in options, unless you want it to throw an exception when API key is null or empty.