From 39ec6730de2094114a8f84c9f861975a36be6d31 Mon Sep 17 00:00:00 2001 From: Jaben Cargman Date: Fri, 17 Oct 2025 00:14:51 -0400 Subject: [PATCH 1/2] Fix hybrid configuration for basic authentication credentials Fixes #67 ## Problem When using hybrid configuration (binding from appsettings.json combined with programmatic overrides), basic authentication credentials set programmatically were not being applied to requests. Users expected to be able to override appsettings.json values with programmatic configuration, particularly for sensitive credentials like BasicAuthUsername and BasicAuthPassword. ## Root Cause The service registration API didn't properly support the .NET options pattern for applying configuration overrides. Options need to be configured using PostConfigure to ensure overrides run after all other configuration sources. ## Solution Simplified the API to use standard .NET options configuration patterns: 1. **Removed custom options helper** - Users now use standard `AddOptions()` 2. **Added non-generic overloads** - `AddGotenbergSharpClient()` defaults to `GotenbergSharpClientOptions` for simpler API 3. **Use `GetRequiredService`** - Changed from nullable `GetService` to fail fast if options aren't registered 4. **Updated documentation** - All examples now show proper use of `PostConfigure` for overrides ## Changes - **TypedClientServiceCollectionExtensions.cs**: Simplified API, added non-generic overloads, updated XML docs - **BasicAuthTests.cs**: Updated tests to use standard options patterns, added hybrid config tests - **README.md**: Fixed all configuration examples to show correct patterns - **DIExample/Program.cs**: Updated to use simplified non-generic API ## Usage ```csharp // Hybrid configuration (appsettings + programmatic overrides) services.AddOptions() .Bind(Configuration.GetSection("GotenbergSharpClient")) .PostConfigure(options => { // These run AFTER binding, so they override appsettings values options.BasicAuthUsername = Environment.GetEnvironmentVariable("GOTENBERG_USER"); options.BasicAuthPassword = Environment.GetEnvironmentVariable("GOTENBERG_PASS"); }); services.AddGotenbergSharpClient(); ``` ## Testing All 9 BasicAuthTests pass, including new tests for: - Hybrid configuration with PostConfigure overrides - Programmatic-only configuration - Incomplete auth validation --- README.md | 48 +++--- .../TypedClientServiceCollectionExtensions.cs | 162 +++++++++++++++--- .../BasicAuthTests.cs | 140 +++++++++++++++ 3 files changed, 304 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index cf360e9..57d8c0a 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(); ..... } ``` @@ -127,15 +130,16 @@ 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"); + } } From c357e54b17dbec8a5e67c250489d1317ca1abf27 Mon Sep 17 00:00:00 2001 From: Jaben Cargman Date: Fri, 17 Oct 2025 20:15:38 -0400 Subject: [PATCH 2/2] fixup --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 57d8c0a..d01836b 100644 --- a/README.md +++ b/README.md @@ -128,19 +128,19 @@ public void ConfigureServices(IServiceCollection services) ```csharp public void ConfigureServices(IServiceCollection services) { - ..... + ..... services.AddOptions() - .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"); - }); + .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"); + }); services.AddGotenbergSharpClient(); - ..... + ..... } ```