From ee60ef05c01ac10d3bf1ec3fad0e6d8e5030ca60 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 19 Sep 2026 16:59:10 +0100 Subject: [PATCH 1/3] Document automatic health monitoring registration configuration --- .../HealthMonitoring.Client.csproj | 2 + .../HealthMonitoringRegistrationExtensions.cs | 58 +++++++++++ HealthMonitoring.Client/README.md | 30 ++++++ ...thMonitoringRegistrationExtensionsTests.cs | 96 +++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 HealthMonitoring.Client/HealthMonitoringRegistrationExtensions.cs create mode 100644 HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs diff --git a/HealthMonitoring.Client/HealthMonitoring.Client.csproj b/HealthMonitoring.Client/HealthMonitoring.Client.csproj index bbed55a..b8ca7c0 100644 --- a/HealthMonitoring.Client/HealthMonitoring.Client.csproj +++ b/HealthMonitoring.Client/HealthMonitoring.Client.csproj @@ -9,6 +9,8 @@ + + diff --git a/HealthMonitoring.Client/HealthMonitoringRegistrationExtensions.cs b/HealthMonitoring.Client/HealthMonitoringRegistrationExtensions.cs new file mode 100644 index 0000000..96d9e28 --- /dev/null +++ b/HealthMonitoring.Client/HealthMonitoringRegistrationExtensions.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HealthMonitoring.Client; + +public sealed class HealthMonitoringRegistrationConfiguration +{ + public Uri MonitoringServerUrl { get; set; } = new("http://localhost:9620/"); + public ServiceRegistrationOptions Service { get; set; } = new(); + public List Dependencies { get; set; } = []; +} + +public sealed class HealthMonitoringRegistrationHostedService( + IHealthMonitoringRegistrationClient client, + HealthMonitoringRegistrationConfiguration configuration) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + await client.RegisterAndConfigureAsync( + configuration.Service, + configuration.Dependencies, + cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public static class HealthMonitoringRegistrationExtensions +{ + public static IServiceCollection AddHealthMonitoringRegistration( + this IServiceCollection services, + IConfiguration configuration, + string sectionName = "HealthMonitoring") + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); + + var section = configuration.GetSection(sectionName); + if (!section.Exists()) + return services; + + var registration = new HealthMonitoringRegistrationConfiguration(); + section.Bind(registration); + if (!registration.MonitoringServerUrl.IsAbsoluteUri) + throw new ArgumentException("The monitoring server URL must be absolute.", nameof(configuration)); + + services.AddSingleton(registration); + services.AddHttpClient(client => + { + client.BaseAddress = registration.MonitoringServerUrl; + }); + services.AddHostedService(); + + return services; + } +} diff --git a/HealthMonitoring.Client/README.md b/HealthMonitoring.Client/README.md index be4816f..0c98c47 100644 --- a/HealthMonitoring.Client/README.md +++ b/HealthMonitoring.Client/README.md @@ -2,6 +2,36 @@ Reusable .NET client for service self-registration and dependency mapping. +For automatic registration at application startup, add this configuration: + +```json +{ + "HealthMonitoring": { + "MonitoringServerUrl": "http://localhost:9620/", + "Service": { + "ServiceId": "payments-api", + "Name": "Payments API", + "HealthUrl": "https://payments-api/health" + }, + "Dependencies": [ + { + "DependencyName": "Security Service", + "TargetServiceId": "security-service" + } + ] + } +} +``` + +Then add one line during service registration: + +```csharp +services.AddHealthMonitoringRegistration(configuration); +``` + +The client registers the service and dependency mappings once during application startup. Registration is idempotent. +If the `HealthMonitoring` section is absent, no registration is attempted and the application starts normally. + ```csharp services.AddHealthMonitoringRegistrationClient(options => { diff --git a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs new file mode 100644 index 0000000..c013f71 --- /dev/null +++ b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs @@ -0,0 +1,96 @@ +using HealthMonitoring.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HealthMonitoring.Tests.Client; + +public sealed class HealthMonitoringRegistrationExtensionsTests +{ + [Fact] + public void Configuration_registration_binds_service_settings_and_adds_startup_registration() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["HealthMonitoring:MonitoringServerUrl"] = "https://monitoring.example/", + ["HealthMonitoring:Service:ServiceId"] = "mobile-configuration", + ["HealthMonitoring:Service:Name"] = "Mobile Configuration", + ["HealthMonitoring:Service:HealthUrl"] = "https://mobile.example/healthui", + ["HealthMonitoring:Service:Description"] = "Mobile Configuration", + ["HealthMonitoring:Service:PollingInterval"] = "00:02:00", + ["HealthMonitoring:Dependencies:0:DependencyName"] = "Security Service", + ["HealthMonitoring:Dependencies:0:TargetServiceId"] = "security-service" + }) + .Build(); + + var services = new ServiceCollection(); + + services.AddHealthMonitoringRegistration(configuration); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService(); + + Assert.Equal(new Uri("https://monitoring.example/"), options.MonitoringServerUrl); + Assert.Equal("mobile-configuration", options.Service.ServiceId); + Assert.Equal(TimeSpan.FromMinutes(2), options.Service.PollingInterval); + Assert.Single(options.Dependencies); + Assert.Contains(provider.GetServices(), service => service is HealthMonitoringRegistrationHostedService); + } + + [Fact] + public void Missing_configuration_section_skips_automatic_registration() + { + var configuration = new ConfigurationBuilder().Build(); + var services = new ServiceCollection(); + + services.AddHealthMonitoringRegistration(configuration); + + using var provider = services.BuildServiceProvider(); + + Assert.Empty(provider.GetServices()); + Assert.Throws(() => + provider.GetRequiredService()); + } + + [Fact] + public async Task Startup_registration_calls_register_and_configure() + { + var configuration = new HealthMonitoringRegistrationConfiguration + { + Service = new ServiceRegistrationOptions + { + ServiceId = "mobile-configuration", + Name = "Mobile Configuration", + HealthUrl = new Uri("https://mobile.example/healthui") + }, + Dependencies = [new DependencyMappingOptions("Security Service", "security-service")] + }; + var client = new RecordingRegistrationClient(); + + await new HealthMonitoringRegistrationHostedService(client, configuration) + .StartAsync(CancellationToken.None); + + Assert.Same(configuration.Service, client.Service); + Assert.Same(configuration.Dependencies, client.Dependencies); + } + + private sealed class RecordingRegistrationClient : IHealthMonitoringRegistrationClient + { + public ServiceRegistrationOptions? Service { get; private set; } + public IEnumerable? Dependencies { get; private set; } + + public Task RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task SetDependencyMappingsAsync(string serviceId, IEnumerable mappings, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable mappings, CancellationToken cancellationToken = default) + { + Service = options; + Dependencies = mappings; + return Task.FromResult(new ServiceRegistrationResult(options.ServiceId, "created", Guid.NewGuid())); + } + } +} From 710f75facccb0b9fe2c278799667c6acf9757140 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 19 Sep 2026 17:17:10 +0100 Subject: [PATCH 2/3] fix: resolve Codacy test double warnings --- .../HealthMonitoringRegistrationExtensionsTests.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs index c013f71..8b6e747 100644 --- a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs +++ b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs @@ -80,11 +80,17 @@ private sealed class RecordingRegistrationClient : IHealthMonitoringRegistration public ServiceRegistrationOptions? Service { get; private set; } public IEnumerable? Dependencies { get; private set; } - public Task RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + public Task RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new NotSupportedException($"RegisterAsync is not used for {options.ServiceId}."); + } - public Task SetDependencyMappingsAsync(string serviceId, IEnumerable mappings, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + public Task SetDependencyMappingsAsync(string serviceId, IEnumerable mappings, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new NotSupportedException($"SetDependencyMappingsAsync is not used for {serviceId} with {mappings.Count()} mappings."); + } public Task RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable mappings, CancellationToken cancellationToken = default) { From a72487925df9092e54becb01295944c14dc1e142 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 19 Sep 2026 17:49:49 +0100 Subject: [PATCH 3/3] fix: honor cancellation in registration test --- .../Client/HealthMonitoringRegistrationExtensionsTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs index 8b6e747..9584cca 100644 --- a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs +++ b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationExtensionsTests.cs @@ -94,6 +94,7 @@ public Task SetDependencyMappingsAsync(string serviceId, IEnumerable RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable mappings, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); Service = options; Dependencies = mappings; return Task.FromResult(new ServiceRegistrationResult(options.ServiceId, "created", Guid.NewGuid()));