From 8e69f36f046fcbcb7b0f55c11cf5363137a76555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20H=C3=A4ggqvist?= Date: Mon, 17 Nov 2025 20:14:42 +0100 Subject: [PATCH] Add WireMockHealthCheck in WireMock.Net.Aspire (#1375) * Add WireMockHealthCheck For use with Aspire, to make WaitFor(wiremock) more useful. Calls /__admin/health and checks the result, as well as checks if mappings using AdminApiMappingBuilder has been submitted to the server. This created a catch-22 problem where the mappings were not submitted until the health check was healthy, but the health check was not healthy until the mappings were submitted. To avoid this, the WireMockServerLifecycleHook class has been slightly re-arranged, and is now using the AfterEndpointsAllocatedAsync callback rather than the AfterResourcesCreatedAsync callback. Within which a separate Task is created that waits until the server is ready and submits the mappings. * Move WireMockMappingState to its own file * Dispose the cancellation tokens in WireMockServerLifecycleHook --- examples-Aspire/AspireApp1.AppHost/Program.cs | 3 +- .../WireMockHealthCheck.cs | 44 +++++++++++++++++++ .../WireMockMappingState.cs | 10 +++++ .../WireMockServerBuilderExtensions.cs | 12 +++++ .../WireMockServerLifecycleHook.cs | 43 ++++++++++++------ .../WireMockServerResource.cs | 4 ++ .../IntegrationTests.cs | 2 + .../WireMockServerBuilderExtensionsTests.cs | 2 +- 8 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/WireMock.Net.Aspire/WireMockHealthCheck.cs create mode 100644 src/WireMock.Net.Aspire/WireMockMappingState.cs diff --git a/examples-Aspire/AspireApp1.AppHost/Program.cs b/examples-Aspire/AspireApp1.AppHost/Program.cs index 854c7e06..451cf92e 100644 --- a/examples-Aspire/AspireApp1.AppHost/Program.cs +++ b/examples-Aspire/AspireApp1.AppHost/Program.cs @@ -45,6 +45,7 @@ IResourceBuilder apiService = builder builder.AddProject("webfrontend") .WithExternalHttpEndpoints() - .WithReference(apiService); + .WithReference(apiService) + .WaitFor(apiService); builder.Build().Run(); \ No newline at end of file diff --git a/src/WireMock.Net.Aspire/WireMockHealthCheck.cs b/src/WireMock.Net.Aspire/WireMockHealthCheck.cs new file mode 100644 index 00000000..2b51cbee --- /dev/null +++ b/src/WireMock.Net.Aspire/WireMockHealthCheck.cs @@ -0,0 +1,44 @@ +// Copyright © WireMock.Net + +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using WireMock.Client; + +namespace WireMock.Net.Aspire; + +/// +/// WireMockHealthCheck +/// +public class WireMockHealthCheck(WireMockServerResource resource) : IHealthCheck +{ + private const string HealthStatusHealthy = "Healthy"; + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!await IsHealthyAsync(resource.AdminApi.Value, cancellationToken)) + { + return HealthCheckResult.Unhealthy("WireMock.Net is not healthy"); + } + + if (resource.ApiMappingState == WireMockMappingState.NotSubmitted) + { + return HealthCheckResult.Unhealthy("WireMock.Net has not received mappings"); + } + + return HealthCheckResult.Healthy(); + } + + private static async Task IsHealthyAsync(IWireMockAdminApi adminApi, CancellationToken cancellationToken) + { + try + { + var status = await adminApi.GetHealthAsync(cancellationToken); + return string.Equals(status, HealthStatusHealthy, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } +} diff --git a/src/WireMock.Net.Aspire/WireMockMappingState.cs b/src/WireMock.Net.Aspire/WireMockMappingState.cs new file mode 100644 index 00000000..2bb82108 --- /dev/null +++ b/src/WireMock.Net.Aspire/WireMockMappingState.cs @@ -0,0 +1,10 @@ +// Copyright © WireMock.Net + +namespace WireMock.Net.Aspire; + +internal enum WireMockMappingState +{ + NoMappings, + NotSubmitted, + Submitted, +} diff --git a/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs b/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs index c522f674..b19e192e 100644 --- a/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs +++ b/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs @@ -3,6 +3,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.WireMock; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Stef.Validation; using WireMock.Client.Builders; @@ -53,11 +54,21 @@ public static class WireMockServerBuilderExtensions Guard.NotNull(arguments); var wireMockContainerResource = new WireMockServerResource(name, arguments); + + var healthCheckKey = $"{name}_check"; + var healthCheckRegistration = new HealthCheckRegistration( + healthCheckKey, + _ => new WireMockHealthCheck(wireMockContainerResource), + failureStatus: null, + tags: null); + builder.Services.AddHealthChecks().Add(healthCheckRegistration); + var resourceBuilder = builder .AddResource(wireMockContainerResource) .WithImage(DefaultLinuxImage) .WithEnvironment(ctx => ctx.EnvironmentVariables.Add("DOTNET_USE_POLLING_FILE_WATCHER", "1")) // https://khalidabuhakmeh.com/aspnet-docker-gotchas-and-workarounds#configuration-reloads-and-filesystemwatcher .WithHttpEndpoint(port: arguments.HttpPort, targetPort: WireMockServerArguments.HttpContainerPort) + .WithHealthCheck(healthCheckKey) .WithWireMockInspectorCommand(); if (!string.IsNullOrEmpty(arguments.MappingsPath)) @@ -172,6 +183,7 @@ public static class WireMockServerBuilderExtensions wiremock.ApplicationBuilder.Services.TryAddLifecycleHook(); wiremock.Resource.Arguments.ApiMappingBuilder = configure; + wiremock.Resource.ApiMappingState = WireMockMappingState.NotSubmitted; return wiremock; } diff --git a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs index d97a4113..57df9ee8 100644 --- a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs +++ b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs @@ -10,32 +10,47 @@ internal class WireMockServerLifecycleHook(ILoggerFactory loggerFactory) : IDist { private readonly CancellationTokenSource _shutdownCts = new(); - public async Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + private CancellationTokenSource? _linkedCts; + private Task? _mappingTask; + + public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { - var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownCts.Token, cancellationToken); + _linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownCts.Token, cancellationToken); - var wireMockServerResources = appModel.Resources - .OfType() - .ToArray(); - - foreach (var wireMockServerResource in wireMockServerResources) + _mappingTask = Task.Run(async () => { - wireMockServerResource.SetLogger(loggerFactory.CreateLogger()); + var wireMockServerResources = appModel.Resources + .OfType() + .ToArray(); - var endpoint = wireMockServerResource.GetEndpoint(); - if (endpoint.IsAllocated) + foreach (var wireMockServerResource in wireMockServerResources) { - await wireMockServerResource.WaitForHealthAsync(cts.Token); + wireMockServerResource.SetLogger(loggerFactory.CreateLogger()); - await wireMockServerResource.CallApiMappingBuilderActionAsync(cts.Token); + var endpoint = wireMockServerResource.GetEndpoint(); + System.Diagnostics.Debug.Assert(endpoint.IsAllocated); - wireMockServerResource.StartWatchingStaticMappings(cts.Token); + await wireMockServerResource.WaitForHealthAsync(_linkedCts.Token); + + await wireMockServerResource.CallApiMappingBuilderActionAsync(_linkedCts.Token); + + wireMockServerResource.StartWatchingStaticMappings(_linkedCts.Token); } - } + }, _linkedCts.Token); + + return Task.CompletedTask; } public async ValueTask DisposeAsync() { await _shutdownCts.CancelAsync(); + + _linkedCts?.Dispose(); + _shutdownCts.Dispose(); + + if (_mappingTask is not null) + { + await _mappingTask; + } } } \ No newline at end of file diff --git a/src/WireMock.Net.Aspire/WireMockServerResource.cs b/src/WireMock.Net.Aspire/WireMockServerResource.cs index e2eac519..528b2100 100644 --- a/src/WireMock.Net.Aspire/WireMockServerResource.cs +++ b/src/WireMock.Net.Aspire/WireMockServerResource.cs @@ -5,6 +5,7 @@ using RestEase; using Stef.Validation; using WireMock.Client; using WireMock.Client.Extensions; +using WireMock.Net.Aspire; using WireMock.Util; // ReSharper disable once CheckNamespace @@ -19,6 +20,7 @@ public class WireMockServerResource : ContainerResource, IResourceWithServiceDis internal WireMockServerArguments Arguments { get; } internal Lazy AdminApi => new(CreateWireMockAdminApi); + internal WireMockMappingState ApiMappingState { get; set; } = WireMockMappingState.NoMappings; private ILogger? _logger; private EnhancedFileSystemWatcher? _enhancedFileSystemWatcher; @@ -64,6 +66,8 @@ public class WireMockServerResource : ContainerResource, IResourceWithServiceDis var mappingBuilder = AdminApi.Value.GetMappingBuilder(); await Arguments.ApiMappingBuilder.Invoke(mappingBuilder, cancellationToken); + + ApiMappingState = WireMockMappingState.Submitted; } internal void StartWatchingStaticMappings(CancellationToken cancellationToken) diff --git a/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs b/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs index e51c2d9b..dc086201 100644 --- a/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs +++ b/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs @@ -19,6 +19,7 @@ public class IntegrationTests(ITestOutputHelper output) var appHostBuilder = await DistributedApplicationTestingBuilder.CreateAsync(); await using var app = await appHostBuilder.BuildAsync(); await app.StartAsync(); + await app.ResourceNotifications.WaitForResourceHealthyAsync("wiremock-service"); using var httpClient = app.CreateHttpClient("wiremock-service"); @@ -46,6 +47,7 @@ public class IntegrationTests(ITestOutputHelper output) var appHostBuilder = await DistributedApplicationTestingBuilder.CreateAsync(); await using var app = await appHostBuilder.BuildAsync(); await app.StartAsync(); + await app.ResourceNotifications.WaitForResourceHealthyAsync("wiremock-service"); var adminClient = app.CreateWireMockAdminClient("wiremock-service"); diff --git a/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs b/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs index a2c1f15d..88ea28a0 100644 --- a/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs +++ b/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs @@ -67,7 +67,7 @@ public class WireMockServerBuilderExtensionsTests MappingsPath = null, HttpPort = port }); - wiremock.Resource.Annotations.Should().HaveCount(5); + wiremock.Resource.Annotations.Should().HaveCount(6); var containerImageAnnotation = wiremock.Resource.Annotations.OfType().FirstOrDefault(); containerImageAnnotation.Should().BeEquivalentTo(new ContainerImageAnnotation