diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ae874..8ea9e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-08-15 + +### Added + +#### NoaaClient Package +- New `NoaaClientOptions.UseProxy` configuration option to route NOAA client requests through a proxy (configured in the `Proxy` section) via `AuroraScienceHub.Framework.Http.Proxy` + +### Changed + +#### NoaaClient Package +- **Breaking:** `AddNoaaClients()` now requires an `IConfiguration` argument (`AddNoaaClients(configuration)`); `NoaaClientOptions` and `ProxyOptions` are bound directly from the provided configuration + ## [1.2.2] - 2026-08-11 ### Fixed @@ -105,8 +117,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Embedded debug symbols in NuGet packages - GitHub Actions CI/CD pipeline for build and test +[1.3.0]: https://github.com/Aurora-Science-Hub/Integrations/compare/1.2.2...1.3.0 +[1.2.2]: https://github.com/Aurora-Science-Hub/Integrations/compare/1.1.1...1.2.2 [1.1.1]: https://github.com/Aurora-Science-Hub/Integrations/compare/1.1.0...1.1.1 [1.1.0]: https://github.com/Aurora-Science-Hub/Integrations/compare/1.0.0...1.1.0 [1.0.0]: https://github.com/Aurora-Science-Hub/Integrations/releases/tag/1.0.0 -[Unreleased]: https://github.com/Aurora-Science-Hub/Integrations/compare/1.1.1...HEAD +[Unreleased]: https://github.com/Aurora-Science-Hub/Integrations/compare/1.3.0...HEAD diff --git a/Directory.Build.props b/Directory.Build.props index a2419b6..3a98c7b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -29,7 +29,7 @@ - 1.2.2 + 1.3.0 diff --git a/README.md b/README.md index bd748d5..5210ae8 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ dotnet add package AuroraScienceHub.Integrations.NoaaClient ```csharp // Register services -builder.Services.AddNoaaClients(); +builder.Services.AddNoaaClients(builder.Configuration); // Inject and use clients public class SpaceWeatherService diff --git a/samples/NoaaClientSample/Program.cs b/samples/NoaaClientSample/Program.cs index 86629ae..ba69a49 100644 --- a/samples/NoaaClientSample/Program.cs +++ b/samples/NoaaClientSample/Program.cs @@ -14,7 +14,7 @@ // Setup DI and configuration var builder = Host.CreateApplicationBuilder(args); builder.Configuration.AddJsonFile("appsettings.json", optional: false); -builder.Services.AddNoaaClients(); +builder.Services.AddNoaaClients(builder.Configuration); var host = builder.Build(); // Get NOAA clients from DI diff --git a/src/NoaaClient/NoaaClientOptions.cs b/src/NoaaClient/NoaaClientOptions.cs index c30e1f4..7ee384d 100644 --- a/src/NoaaClient/NoaaClientOptions.cs +++ b/src/NoaaClient/NoaaClientOptions.cs @@ -17,6 +17,11 @@ public sealed class NoaaClientOptions /// public Uri? ServerUrl { get; set; } + /// + /// Use a proxy + /// + public bool UseProxy { get; set; } = false; + /// /// Gets the required server URL. Throws if not set. /// diff --git a/src/NoaaClient/README.md b/src/NoaaClient/README.md index 1789b96..6f5bb33 100644 --- a/src/NoaaClient/README.md +++ b/src/NoaaClient/README.md @@ -17,15 +17,26 @@ dotnet add package AuroraScienceHub.Integrations.NoaaClient ```json { "Noaa": { - "ServerUrl": "https://services.swpc.noaa.gov" + "ServerUrl": "https://services.swpc.noaa.gov", + "UseProxy": false + }, + "Proxy": { + "Address": "http://proxy.example.com:8080", + "UserName": "", + "Password": "" } } ``` +- **Noaa:ServerUrl** — NOAA SWPC base URL (required). +- **Noaa:UseProxy** — when `true`, client requests are routed through the proxy configured in the `Proxy` section. Default: `false`. +- **Proxy:Address** — proxy server URI (required when `UseProxy` is enabled; startup fails with `InvalidOperationException` if missing). +- **Proxy:UserName** / **Proxy:Password** — optional proxy credentials. When omitted, default credentials are used. + Register clients in DI: ```csharp -builder.Services.AddNoaaClients(); +builder.Services.AddNoaaClients(builder.Configuration); ``` ## RTSW Client diff --git a/src/NoaaClient/ServiceCollectionExtensions.cs b/src/NoaaClient/ServiceCollectionExtensions.cs index bab307a..5b92a30 100644 --- a/src/NoaaClient/ServiceCollectionExtensions.cs +++ b/src/NoaaClient/ServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ +using AuroraScienceHub.Framework.Http.Proxy; using AuroraScienceHub.Integrations.NoaaClient.Ace; -using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; using AuroraScienceHub.Integrations.NoaaClient.KpIndex; using AuroraScienceHub.Integrations.NoaaClient.Rtsw; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace AuroraScienceHub.Integrations.NoaaClient; @@ -14,21 +16,50 @@ public static class ServiceCollectionExtensions /// /// Adds NOAA clients to the service collection. /// + /// The to add services to. + /// The application configuration used to read the section. /// /// Registers for backward compatibility only; prefer . + /// + /// When is enabled in configuration, client requests are routed + /// through the proxy configured in the Proxy section (see ). + /// /// - public static IServiceCollection AddNoaaClients(this IServiceCollection services) + public static IServiceCollection AddNoaaClients( + this IServiceCollection services, + IConfiguration configuration) { services.AddOptions() - .BindConfiguration(NoaaClientOptions.OptionKey); + .Bind(configuration.GetSection(NoaaClientOptions.OptionKey)); + + services.AddOptions() + .Bind(configuration.GetSection(ProxyOptions.OptionKey)); + + var useProxy = configuration + .GetSection(NoaaClientOptions.OptionKey) + .GetValue(nameof(NoaaClientOptions.UseProxy)); #pragma warning disable CS0618 // ACE client registration pending removal in issue #3. - services.AddHttpClient(); + services.AddNoaaHttpClient(useProxy); #pragma warning restore CS0618 - services.AddHttpClient(); - services.AddHttpClient(); - services.AddHttpClient(); + services.AddNoaaHttpClient(useProxy); + services.AddNoaaHttpClient(useProxy); + services.AddNoaaHttpClient(useProxy); return services; } + + private static void AddNoaaHttpClient( + this IServiceCollection services, + bool useProxy) + where TClient : class + where TImplementation : class, TClient + { + var builder = services.AddHttpClient(); + + if (useProxy) + { + builder.ConfigurePrimaryHttpProxyMessageHandler(); + } + } } diff --git a/tests/UnitTests/NoaaClient/ServiceCollectionExtensionsTests.cs b/tests/UnitTests/NoaaClient/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..6f2ceaf --- /dev/null +++ b/tests/UnitTests/NoaaClient/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Reflection; +using AuroraScienceHub.Integrations.NoaaClient; +using AuroraScienceHub.Integrations.NoaaClient.Rtsw; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Shouldly; + +namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient; + +/// +/// Tests for proxy configuration. +/// +public sealed class ServiceCollectionExtensionsTests +{ + private static readonly Uri ProxyAddress = new("http://proxy.example.com:8080"); + + [Fact(DisplayName = "Noaa clients do not use a custom proxy when UseProxy is not enabled")] + public void AddNoaaClients_WhenUseProxyDisabled_ConfiguresClientWithoutProxy() + { + // Arrange / Act + var (_, proxy) = CreatePrimaryHandlerProxy(CreateConfiguration(useProxy: false), typeof(IRtswClient)); + + // Assert + proxy.ShouldBeNull(); + } + + [Fact(DisplayName = "Noaa clients route through the configured proxy when UseProxy is enabled")] + public void AddNoaaClients_WhenUseProxyEnabled_ConfiguresProxyAddress() + { + // Arrange / Act + var (useProxy, proxy) = CreatePrimaryHandlerProxy( + CreateConfiguration(useProxy: true, address: ProxyAddress), + typeof(IRtswClient)); + + // Assert + useProxy.ShouldBeTrue(); + var webProxy = proxy.ShouldBeOfType(); + webProxy.Address.ShouldBe(ProxyAddress); + } + + [Fact(DisplayName = "Noaa clients apply proxy credentials when configured")] + public void AddNoaaClients_WhenProxyCredentialsConfigured_AppliesCredentials() + { + // Arrange / Act + var (_, proxy) = CreatePrimaryHandlerProxy( + CreateConfiguration(useProxy: true, address: ProxyAddress, userName: "user", password: "pass"), + typeof(IRtswClient)); + + // Assert + var webProxy = proxy.ShouldBeOfType(); + var credentials = webProxy.Credentials.ShouldBeOfType(); + credentials.UserName.ShouldBe("user"); + credentials.Password.ShouldBe("pass"); + webProxy.UseDefaultCredentials.ShouldBeFalse(); + } + + [Fact(DisplayName = "Noaa clients fail fast when UseProxy is enabled but the proxy address is missing")] + public void AddNoaaClients_WhenUseProxyEnabledWithoutAddress_ThrowsInvalidOperationException() + { + // Arrange / Act + var exception = Should.Throw( + () => CreatePrimaryHandlerProxy(CreateConfiguration(useProxy: true), typeof(IRtswClient))); + + // Assert + exception.Message.ShouldContain("Proxy"); + } + + private static (bool UseProxy, IWebProxy? Proxy) CreatePrimaryHandlerProxy( + IConfiguration configuration, + Type clientType) + { + var services = new ServiceCollection(); + services.AddNoaaClients(configuration); + + using var provider = services.BuildServiceProvider(); + + var handlerFactory = provider.GetRequiredService(); + var handler = handlerFactory.CreateHandler(clientType.Name); + var primaryHandler = UnwrapPrimaryHandler(handler); + + return primaryHandler switch + { + HttpClientHandler httpClientHandler => (httpClientHandler.UseProxy, httpClientHandler.Proxy), + SocketsHttpHandler socketsHandler => (socketsHandler.UseProxy, socketsHandler.Proxy), + _ => throw new InvalidOperationException( + $"Unexpected primary handler type {primaryHandler.GetType().Name}."), + }; + } + + private static HttpMessageHandler UnwrapPrimaryHandler(HttpMessageHandler handler) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + while (true) + { + if (handler is HttpClientHandler or SocketsHttpHandler) + { + return handler; + } + + var innerHandlerProperty = handler.GetType().GetProperty("InnerHandler", flags) + ?? throw new InvalidOperationException( + $"Cannot unwrap message handler of type {handler.GetType().Name}."); + + handler = (HttpMessageHandler)innerHandlerProperty.GetValue(handler)!; + } + } + + private static IConfiguration CreateConfiguration( + bool useProxy, + Uri? address = null, + string? userName = null, + string? password = null) + { + var data = new Dictionary + { + [$"{NoaaClientOptions.OptionKey}:ServerUrl"] = "https://noaa.test", + [$"{NoaaClientOptions.OptionKey}:UseProxy"] = useProxy.ToString(), + }; + + if (address is not null) + { + data["Proxy:Address"] = address.ToString(); + } + + if (userName is not null) + { + data["Proxy:UserName"] = userName; + } + + if (password is not null) + { + data["Proxy:Password"] = password; + } + + return new ConfigurationBuilder() + .AddInMemoryCollection(data) + .Build(); + } +}