diff --git a/README.md b/README.md index cf360e9..d01836b 100644 --- a/README.md +++ b/README.md @@ -102,21 +102,24 @@ public void ConfigureServices(IServiceCollection services) { ..... // Configure with an action - services.AddGotenbergSharpClient(options => - { - options.ServiceUrl = new Uri("http://localhost:3000"); - options.TimeOut = TimeSpan.FromMinutes(5); - options.BasicAuthUsername = "username"; - options.BasicAuthPassword = "password"; - // Configure retry policy - options.RetryPolicy = new RetryPolicyOptions + services.AddOptions() + .Configure(options => { - Enabled = true, - RetryCount = 4, - BackoffPower = 1.5, - LoggingEnabled = true - }; - }); + options.ServiceUrl = new Uri("http://localhost:3000"); + options.TimeOut = TimeSpan.FromMinutes(5); + options.BasicAuthUsername = "username"; + options.BasicAuthPassword = "password"; + // Configure retry policy + options.RetryPolicy = new RetryOptions + { + Enabled = true, + RetryCount = 4, + BackoffPower = 1.5, + LoggingEnabled = true + }; + }); + + services.AddGotenbergSharpClient(); ..... } ``` @@ -125,18 +128,19 @@ public void ConfigureServices(IServiceCollection services) ```csharp public void ConfigureServices(IServiceCollection services) { - ..... + ..... services.AddOptions() - .Bind(Configuration.GetSection(nameof(GotenbergSharpClient))); + .Bind(Configuration.GetSection(nameof(GotenbergSharpClient))) + .PostConfigure(options => + { + // Override or add settings programmatically (runs after binding) + options.TimeOut = TimeSpan.FromMinutes(10); // Override timeout + options.BasicAuthUsername = Environment.GetEnvironmentVariable("GOTENBERG_USER"); + options.BasicAuthPassword = Environment.GetEnvironmentVariable("GOTENBERG_PASS"); + }); - // Override or add settings programmatically - services.AddGotenbergSharpClient(options => - { - options.TimeOut = TimeSpan.FromMinutes(10); // Override timeout - options.BasicAuthUsername = Environment.GetEnvironmentVariable("GOTENBERG_USER"); - options.BasicAuthPassword = Environment.GetEnvironmentVariable("GOTENBERG_PASS"); - }); - ..... + services.AddGotenbergSharpClient(); + ..... } ``` diff --git a/src/Gotenberg.Sharp.Api.Client/Extensions/TypedClientServiceCollectionExtensions.cs b/src/Gotenberg.Sharp.Api.Client/Extensions/TypedClientServiceCollectionExtensions.cs index 28b59ff..6b450d6 100644 --- a/src/Gotenberg.Sharp.Api.Client/Extensions/TypedClientServiceCollectionExtensions.cs +++ b/src/Gotenberg.Sharp.Api.Client/Extensions/TypedClientServiceCollectionExtensions.cs @@ -30,31 +30,78 @@ public static class TypedClientServiceCollectionExtensions { /// /// Registers GotenbergSharpClient with dependency injection using configured options. - /// Configure options via appsettings.json or by calling services.Configure<GotenbergSharpClientOptions>(). /// /// The service collection. - /// Optional function to configure client options after they are retrieved. /// An IHttpClientBuilder for further configuration. /// Thrown when services is null. /// - /// This method configures the HttpClient with automatic compression, retry policies, and basic authentication if - /// credentials are provided. - /// Options should be configured in the "GotenbergSharpClient" section of appsettings.json or programmatically. + /// + /// This method registers the GotenbergSharpClient with automatic compression, retry policies, + /// and basic authentication if credentials are provided in the options. + /// + /// + /// Options must be registered before calling this method using + /// standard .NET options configuration methods. + /// The client retrieves options from the DI container using IOptions<TOptions>. + /// + /// + /// Example usage: + /// + /// services.AddOptions<GotenbergSharpClientOptions>() + /// .Bind(configuration.GetSection("GotenbergSharpClient")); + /// services.AddGotenbergSharpClient(); + /// + /// /// public static IHttpClientBuilder AddGotenbergSharpClient( - this IServiceCollection services, - Action? configureClientOptions = null) + this IServiceCollection services) { if (services == null) { throw new ArgumentNullException(nameof(services)); } - return services.AddGotenbergSharpClient((sp, client) => + return services.AddGotenbergSharpClient(); + } + + /// + /// Registers GotenbergSharpClient with dependency injection using configured options. + /// + /// The options type, must inherit from GotenbergSharpClientOptions. + /// The service collection. + /// An IHttpClientBuilder for further configuration. + /// Thrown when services is null. + /// + /// + /// This method registers the GotenbergSharpClient with automatic compression, retry policies, + /// and basic authentication if credentials are provided in the options. + /// + /// + /// Options must be registered before calling this method using + /// standard .NET options configuration methods. + /// The client retrieves options from the DI container using IOptions<TOptions>. + /// + /// + /// Example usage: + /// + /// services.AddOptions<GotenbergSharpClientOptions>() + /// .Bind(configuration.GetSection("GotenbergSharpClient")); + /// services.AddGotenbergSharpClient(); + /// + /// + /// + public static IHttpClientBuilder AddGotenbergSharpClient( + this IServiceCollection services) + where TOptions : GotenbergSharpClientOptions, new() + { + if (services == null) { - var ops = GetOptions(sp) ?? new GotenbergSharpClientOptions(); + throw new ArgumentNullException(nameof(services)); + } - configureClientOptions?.Invoke(ops); + return services.AddGotenbergSharpClient((sp, client) => + { + var ops = sp.GetRequiredService>().Value; client.Timeout = ops.TimeOut; client.BaseAddress = ops.ServiceUrl; @@ -65,18 +112,92 @@ public static IHttpClientBuilder AddGotenbergSharpClient( /// Registers GotenbergSharpClient with dependency injection using a custom HttpClient configuration. /// /// The service collection. - /// Action to configure the HttpClient instance. - /// Optional function to configure client options after they are retrieved. + /// + /// Action to configure the HttpClient instance. The action receives the service provider and HttpClient + /// for custom configuration. + /// /// An IHttpClientBuilder for further configuration. /// Thrown when configureClient is null. /// - /// This overload allows full control over HttpClient configuration. The client is configured with - /// automatic compression, timeout handling, and exponential backoff retry policies. + /// + /// This overload allows full control over HttpClient configuration while still using the options + /// for basic authentication and retry policies. The client is configured with automatic compression, + /// timeout handling, and exponential backoff retry policies based on the registered options. + /// + /// + /// Options must be registered before calling this method using + /// standard .NET options configuration methods. + /// + /// + /// Example usage: + /// + /// services.AddOptions<GotenbergSharpClientOptions>() + /// .Bind(configuration.GetSection("GotenbergSharpClient")) + /// .PostConfigure(options => + /// { + /// options.BasicAuthUsername = "user"; + /// options.BasicAuthPassword = "pass"; + /// }); + /// + /// services.AddGotenbergSharpClient((sp, client) => + /// { + /// // Custom HttpClient configuration + /// client.DefaultRequestHeaders.Add("X-Custom-Header", "value"); + /// }); + /// + /// /// public static IHttpClientBuilder AddGotenbergSharpClient( this IServiceCollection services, - Action configureClient, - Action? configureClientOptions = null) + Action configureClient) + { + return services.AddGotenbergSharpClient(configureClient); + } + + /// + /// Registers GotenbergSharpClient with dependency injection using a custom HttpClient configuration. + /// + /// The options type, must inherit from GotenbergSharpClientOptions. + /// The service collection. + /// + /// Action to configure the HttpClient instance. The action receives the service provider and HttpClient + /// for custom configuration. + /// + /// An IHttpClientBuilder for further configuration. + /// Thrown when configureClient is null. + /// + /// + /// This overload allows full control over HttpClient configuration while still using the options + /// for basic authentication and retry policies. The client is configured with automatic compression, + /// timeout handling, and exponential backoff retry policies based on the registered options. + /// + /// + /// Options must be registered before calling this method using + /// standard .NET options configuration methods. + /// + /// + /// Example usage: + /// + /// services.AddOptions<GotenbergSharpClientOptions>() + /// .Bind(configuration.GetSection("GotenbergSharpClient")) + /// .PostConfigure(options => + /// { + /// options.BasicAuthUsername = "user"; + /// options.BasicAuthPassword = "pass"; + /// }); + /// + /// services.AddGotenbergSharpClient((sp, client) => + /// { + /// // Custom HttpClient configuration + /// client.DefaultRequestHeaders.Add("X-Custom-Header", "value"); + /// }); + /// + /// + /// + public static IHttpClientBuilder AddGotenbergSharpClient( + this IServiceCollection services, + Action configureClient) + where TOptions : GotenbergSharpClientOptions, new() { if (configureClient == null) { @@ -94,9 +215,7 @@ public static IHttpClientBuilder AddGotenbergSharpClient( })) .AddHttpMessageHandler(sp => { - var ops = GetOptions(sp) ?? new GotenbergSharpClientOptions(); - - configureClientOptions?.Invoke(ops); + var ops = sp.GetRequiredService>().Value; var hasUsername = !string.IsNullOrWhiteSpace(ops.BasicAuthUsername); var hasPassword = !string.IsNullOrWhiteSpace(ops.BasicAuthPassword); @@ -122,9 +241,4 @@ public static IHttpClientBuilder AddGotenbergSharpClient( return builder; } - - private static GotenbergSharpClientOptions? GetOptions(IServiceProvider sp) - { - return sp.GetService>()?.Value; - } } \ No newline at end of file diff --git a/test/GotenbergSharpClient.Tests/BasicAuthTests.cs b/test/GotenbergSharpClient.Tests/BasicAuthTests.cs index 36c05c6..6f477f0 100644 --- a/test/GotenbergSharpClient.Tests/BasicAuthTests.cs +++ b/test/GotenbergSharpClient.Tests/BasicAuthTests.cs @@ -127,4 +127,144 @@ public void GotenbergSharpClientOptions_WithBasicAuthSet_ShouldRetainValues() options.BasicAuthUsername.Should().Be(TestUsername); options.BasicAuthPassword.Should().Be(TestPassword); } + + [Test] + public void Client_WithHybridConfiguration_ShouldApplyProgrammaticOverrides() + { + // Arrange + var services = new ServiceCollection(); + + // Hybrid configuration: Start with binding from configuration (simulating appsettings.json) + // then apply programmatic overrides via PostConfigure + services.AddOptions() + .Configure(options => + { + // Base configuration (simulating appsettings.json binding) + options.ServiceUrl = new Uri(GotenbergUrl); + options.TimeOut = TimeSpan.FromMinutes(3); + // BasicAuth not set in "appsettings" + }) + .PostConfigure(options => + { + // Programmatic overrides applied via PostConfigure + options.BasicAuthUsername = TestUsername; + options.BasicAuthPassword = TestPassword; + options.TimeOut = TimeSpan.FromMinutes(10); + }); + + services.AddGotenbergSharpClient(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act - Verify options were properly configured with overrides + var resolvedOptions = serviceProvider.GetRequiredService>().Value; + + // Assert - PostConfigure should have applied overrides after Configure + resolvedOptions.BasicAuthUsername.Should().Be(TestUsername, "PostConfigure should override with username"); + resolvedOptions.BasicAuthPassword.Should().Be(TestPassword, "PostConfigure should override with password"); + resolvedOptions.TimeOut.Should().Be(TimeSpan.FromMinutes(10), "PostConfigure should override timeout"); + resolvedOptions.ServiceUrl.Should().Be(new Uri(GotenbergUrl), "Configure should set base values"); + } + + [Test] + public async Task Client_WithHybridConfigurationAndRunningGotenberg_ShouldAuthenticate() + { + // This test requires a running Gotenberg instance with basic auth enabled + // It will be skipped in CI/CD environments where Gotenberg isn't running + + // Arrange + var services = new ServiceCollection(); + + // Hybrid configuration: base configuration + programmatic overrides + services.AddOptions() + .Configure(options => + { + // Base configuration (simulating appsettings.json) + options.ServiceUrl = new Uri(GotenbergUrl); + options.TimeOut = TimeSpan.FromMinutes(3); + }) + .PostConfigure(options => + { + // Programmatic overrides for credentials + options.BasicAuthUsername = TestUsername; + options.BasicAuthPassword = TestPassword; + }); + + services.AddGotenbergSharpClient(); + + var serviceProvider = services.BuildServiceProvider(); + var client = serviceProvider.GetRequiredService(); + + // Act - Create a simple HTML to PDF request + var builder = new HtmlRequestBuilder() + .AddDocument(doc => doc.SetBody("

Hybrid Config Test

")); + + // Assert - Should succeed with the programmatically configured auth + var result = await client.HtmlToPdfAsync(builder); + + result.Should().NotBeNull("Hybrid configuration with basic auth should work properly"); + result.Length.Should().BeGreaterThan(0); + } + + [Test] + public void Client_WithHybridConfiguration_ShouldFailWithIncompleteAuth() + { + // Arrange + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(options => + { + options.ServiceUrl = new Uri(GotenbergUrl); + }) + .PostConfigure(options => + { + // Incomplete auth - only username provided + options.BasicAuthUsername = TestUsername; + // BasicAuthPassword deliberately omitted - should fail validation + }); + + services.AddGotenbergSharpClient(); + + // Act & Assert - Building the client should throw when validation occurs + var act = () => + { + var serviceProvider = services.BuildServiceProvider(); + // Trigger the HttpMessageHandler factory which contains the validation + var client = serviceProvider.GetRequiredService(); + }; + + act.Should().Throw("Incomplete basic auth configuration should fail validation") + .WithMessage("*BasicAuth configuration is incomplete*"); + } + + [Test] + public void Client_WithProgrammaticOnlyConfiguration_ShouldApplyBasicAuth() + { + // Arrange - Programmatic configuration only (no appsettings.json binding) + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(options => + { + // All configuration is programmatic + options.ServiceUrl = new Uri(GotenbergUrl); + options.BasicAuthUsername = TestUsername; + options.BasicAuthPassword = TestPassword; + options.TimeOut = TimeSpan.FromMinutes(5); + }); + + services.AddGotenbergSharpClient(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act - Verify options were properly configured + var resolvedOptions = serviceProvider.GetRequiredService>().Value; + + // Assert - All programmatic values should be applied + resolvedOptions.BasicAuthUsername.Should().Be(TestUsername, "Programmatic-only config should set username"); + resolvedOptions.BasicAuthPassword.Should().Be(TestPassword, "Programmatic-only config should set password"); + resolvedOptions.TimeOut.Should().Be(TimeSpan.FromMinutes(5), "Programmatic-only config should set timeout"); + resolvedOptions.ServiceUrl.Should().Be(new Uri(GotenbergUrl), "Programmatic-only config should set service URL"); + } }