Search code examples
c#.netdependency-injectionextension-methods

Unable to configure services using the extension method in .net


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?


Solution

  • You were pretty close to getting it right, yet two things were holding you back:

    1. Registering HttpClient for service using implementation type

    When 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 appropriate HttpClient can be retrieved from IServiceProvider.GetService(Type) (and related methods) by providing THttpClient 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;
    }
    

    2. Duplicated registration of 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:

    enter image description here

    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.