I am struggling to find the right way to offer custom HttpClients configuration in a C# wrapper library I have written to consume a third-party API. I have created a separate branch in my repo where the extension method to configure the library is written and related DI implementation is done.
To consume library:
builder.Services.AddGemini<GeminiClient, GeminiHttpClientOptions>(option =>
{
option.Credentials = new GeminiConfiguration
{
ApiKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
};
});
AddGemini represents the extension method.
public static class GeminiExtensions
{
public static IServiceCollection AddGemini<THttpClient, THttpClientOptions>(
this IServiceCollection services,
Action<THttpClientOptions> configure) where THttpClient : class where THttpClientOptions : class, IGeminiAuthHttpClientOptions
{
services
.AddOptions<THttpClientOptions>()
.Configure(configure);
services.AddTransient<GeminiAuthHandler<THttpClientOptions>>();
services.AddHttpClient<THttpClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<THttpClientOptions>>().Value;
client.BaseAddress = options.Url;
})
.AddHttpMessageHandler<GeminiAuthHandler<THttpClientOptions>>();
services.AddTransient<ITextService, TextService>();
return services;
}
}
The GeminiClient represents a typed HttpClient.
public class GeminiClient
{
private readonly HttpClient _httpClient;
public GeminiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<TResponse> GetAsync<TResponse>(string endpoint)
{
var response = await _httpClient.GetAsync(endpoint);
return await HandleResponse<TResponse>(response)
?? throw new GeminiException("The API has returned a null response.");
}
public async Task<TResponse> PostAsync<TRequest, TResponse>(string endpoint, TRequest data)
{
var serializedContent = JsonSerializer.Serialize(data, options: new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
var jsonContent = new StringContent(serializedContent, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(endpoint, jsonContent);
return await HandleResponse<TResponse>(response)
?? throw new GeminiException("The API has returned a null response.");
}
private static async Task<T> HandleResponse<T>(HttpResponseMessage response)
{
var content = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
return JsonSerializer.Deserialize<T>(content)
?? throw new GeminiException("The API has returned a null response.");
}
else
{
var geminiError = JsonSerializer.Deserialize<ApiErrorResponse>(content)
?? throw new GeminiException("The API has returned a null response.");
throw new GeminiException(geminiError, geminiError.error.message);
}
}
}
I want to offer the consumer to use a custom HTTP client of their choice with any service they like, say, use the default HTTP client for TextService and use a custom HttpClient with proxy configuration for ChatService.
To do so, I can allow consumers to have multiple typed HTTP clients but why re-write the entire client implementation for each service? There has to be another simple way of using the HttpClient with the service of your choice.
Link to the DI branch in the repo that contains the stuff I have implemented: https://github.com/jaslam94/Junaid.GoogleGemini.Net/tree/feature/di
Update
I have gone ahead with the custom-typed HTTP client logic. Here is my updated extension method:
public static IServiceCollection AddGemini(this IServiceCollection services)
{
services.AddTransient<GeminiAuthHandler<GeminiHttpClientOptions>>();
services.AddHttpClient<GeminiClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<GeminiHttpClientOptions>>().Value;
client.BaseAddress = options.Url;
})
.AddHttpMessageHandler<GeminiAuthHandler<GeminiHttpClientOptions>>();
services.AddTransient<ITextService, TextService>();
services.AddTransient<IVisionService, VisionService>();
services.AddTransient<IChatService, ChatService>();
services.AddTransient<IEmbeddingService, EmbeddingService>();
services.AddTransient<IModelInfoService, ModelInfoService>();
return services;
}
This is the default AddGemini and related implementation that works out of the box. The user has to call this method and everything gets to the services container.
builder.Services.Configure<GeminiHttpClientOptions>(builder.Configuration.GetSection("Gemini"));
Then call AddGemini extension method (which configures a typed HttpClient named GeminiClient and all of the library services).
builder.Services.AddGemini();
However, when a custom-typed HttpClient is needed, the entire extension method needs to be rewritten to configure things, which doesn't seem like the right way of doing it.
Created a new Typed HttpClient:
public class CustomClient : GeminiClient { public CustomClient(HttpClient httpClient) : base(httpClient) { } }Add relevant configuration to the Typed HttpClient and register it with the DI container:
builder.Services.AddHttpClient<GeminiClient, CustomClient>((sp, client) => { var options = sp.GetRequiredService<IOptions<GeminiHttpClientOptions>>().Value; client.BaseAddress = options.Url; }) .ConfigurePrimaryHttpMessageHandler(() => { var proxy = new WebProxy { Address = new Uri("http://localhost:1080/") }; var httpClientHandler = new HttpClientHandler { Proxy = proxy, UseProxy = true }; //Not recommended for production httpClientHandler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; return httpClientHandler; }) .AddHttpMessageHandler<GeminiAuthHandler<GeminiHttpClientOptions>>();Register the required service:
builder.Services.AddTransient<ITextService, TextService>();
If the user calls AddGemini and then goes ahead with registering a new custom-typed client, the previous one stays in the pipeline and I think it becomes unnecessary. There has to be a way to replace the existing registered client from the services container if the user wants.
How to improve the configuration process in the case of a custom-typed HTTP client? How to remove the previously registered client if the need be in a clean consumer-friendly way?