Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HealthMonitoring.Client/HealthMonitoring.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="" />
Expand Down
58 changes: 58 additions & 0 deletions HealthMonitoring.Client/HealthMonitoringRegistrationExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<DependencyMappingOptions> 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);
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
if (!registration.MonitoringServerUrl.IsAbsoluteUri)
throw new ArgumentException("The monitoring server URL must be absolute.", nameof(configuration));

services.AddSingleton(registration);
services.AddHttpClient<IHealthMonitoringRegistrationClient, HealthMonitoringRegistrationClient>(client =>
{
client.BaseAddress = registration.MonitoringServerUrl;
});
services.AddHostedService<HealthMonitoringRegistrationHostedService>();

return services;
}
}
30 changes: 30 additions & 0 deletions HealthMonitoring.Client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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<string, string?>
{
["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<HealthMonitoringRegistrationConfiguration>();

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<IHostedService>(), 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<IHostedService>());
Assert.Throws<InvalidOperationException>(() =>
provider.GetRequiredService<IHealthMonitoringRegistrationClient>());
}

[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<DependencyMappingOptions>? Dependencies { get; private set; }

public Task<ServiceRegistrationResult> RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
throw new NotSupportedException($"RegisterAsync is not used for {options.ServiceId}.");
}

public Task SetDependencyMappingsAsync(string serviceId, IEnumerable<DependencyMappingOptions> mappings, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
throw new NotSupportedException($"SetDependencyMappingsAsync is not used for {serviceId} with {mappings.Count()} mappings.");
}

public Task<ServiceRegistrationResult> RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable<DependencyMappingOptions> mappings, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Service = options;
Dependencies = mappings;
return Task.FromResult(new ServiceRegistrationResult(options.ServiceId, "created", Guid.NewGuid()));
}
}
}
Loading