From 2c4d484a131613be83f6a9e274af200b1c54684d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20H=C3=A4ggqvist?= Date: Tue, 4 Nov 2025 15:32:01 +0100 Subject: [PATCH 1/3] 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. --- examples-Aspire/AspireApp1.AppHost/Program.cs | 3 +- .../WireMockHealthCheck.cs | 44 +++++++++++++++++++ .../WireMockServerBuilderExtensions.cs | 12 +++++ .../WireMockServerLifecycleHook.cs | 26 ++++++----- .../WireMockServerResource.cs | 10 +++++ .../IntegrationTests.cs | 2 + .../WireMockServerBuilderExtensionsTests.cs | 2 +- 7 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 src/WireMock.Net.Aspire/WireMockHealthCheck.cs diff --git a/examples-Aspire/AspireApp1.AppHost/Program.cs b/examples-Aspire/AspireApp1.AppHost/Program.cs index 854c7e067..451cf92ef 100644 --- a/examples-Aspire/AspireApp1.AppHost/Program.cs +++ b/examples-Aspire/AspireApp1.AppHost/Program.cs @@ -45,6 +45,7 @@ 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 000000000..4fde54362 --- /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 == WireMockServerResource.MappingState.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/WireMockServerBuilderExtensions.cs b/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs index c522f674c..60c3f31d4 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 IResourceBuilder AddWireMock(this IDistrib 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 IResourceBuilder WithApiMappingBuilder(thi wiremock.ApplicationBuilder.Services.TryAddLifecycleHook(); wiremock.Resource.Arguments.ApiMappingBuilder = configure; + wiremock.Resource.ApiMappingState = WireMockServerResource.MappingState.NotSubmitted; return wiremock; } diff --git a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs index d97a41132..c8f0c717f 100644 --- a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs +++ b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs @@ -10,32 +10,38 @@ internal class WireMockServerLifecycleHook(ILoggerFactory loggerFactory) : IDist { private readonly CancellationTokenSource _shutdownCts = new(); - public async Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + private Task? _mappingTask; + + public async Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { var cts = 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) { + wireMockServerResource.SetLogger(loggerFactory.CreateLogger()); + + var endpoint = wireMockServerResource.GetEndpoint(); + System.Diagnostics.Debug.Assert(endpoint.IsAllocated); + await wireMockServerResource.WaitForHealthAsync(cts.Token); await wireMockServerResource.CallApiMappingBuilderActionAsync(cts.Token); wireMockServerResource.StartWatchingStaticMappings(cts.Token); } - } + }, cts.Token); } public async ValueTask DisposeAsync() { await _shutdownCts.CancelAsync(); + 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 e2eac519c..3fda524dd 100644 --- a/src/WireMock.Net.Aspire/WireMockServerResource.cs +++ b/src/WireMock.Net.Aspire/WireMockServerResource.cs @@ -20,6 +20,14 @@ public class WireMockServerResource : ContainerResource, IResourceWithServiceDis internal WireMockServerArguments Arguments { get; } internal Lazy AdminApi => new(CreateWireMockAdminApi); + internal enum MappingState + { + NoMappings, + NotSubmitted, + Submitted, + } + internal MappingState ApiMappingState { get; set; } = MappingState.NoMappings; + private ILogger? _logger; private EnhancedFileSystemWatcher? _enhancedFileSystemWatcher; @@ -64,6 +72,8 @@ internal async Task CallApiMappingBuilderActionAsync(CancellationToken cancellat var mappingBuilder = AdminApi.Value.GetMappingBuilder(); await Arguments.ApiMappingBuilder.Invoke(mappingBuilder, cancellationToken); + + ApiMappingState = MappingState.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 e51c2d9b7..dc0862010 100644 --- a/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs +++ b/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs @@ -19,6 +19,7 @@ public async Task StartAppHostWithWireMockAndCreateHttpClientToCallTheMockedWeat 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 async Task StartAppHostWithWireMockAndCreateWireMockAdminClientToCallTheA 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 a2c1f15d4..88ea28a05 100644 --- a/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs +++ b/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs @@ -67,7 +67,7 @@ public void AddWireMock() 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 From c1a05d9f519e221aa33ab3ce3d21ba069ce95640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20H=C3=A4ggqvist?= Date: Mon, 17 Nov 2025 14:01:02 +0100 Subject: [PATCH 2/3] Move WireMockMappingState to its own file --- src/WireMock.Net.Aspire/WireMockHealthCheck.cs | 2 +- src/WireMock.Net.Aspire/WireMockMappingState.cs | 10 ++++++++++ .../WireMockServerBuilderExtensions.cs | 2 +- src/WireMock.Net.Aspire/WireMockServerResource.cs | 12 +++--------- 4 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 src/WireMock.Net.Aspire/WireMockMappingState.cs diff --git a/src/WireMock.Net.Aspire/WireMockHealthCheck.cs b/src/WireMock.Net.Aspire/WireMockHealthCheck.cs index 4fde54362..2b51cbee8 100644 --- a/src/WireMock.Net.Aspire/WireMockHealthCheck.cs +++ b/src/WireMock.Net.Aspire/WireMockHealthCheck.cs @@ -21,7 +21,7 @@ public async Task CheckHealthAsync(HealthCheckContext context return HealthCheckResult.Unhealthy("WireMock.Net is not healthy"); } - if (resource.ApiMappingState == WireMockServerResource.MappingState.NotSubmitted) + if (resource.ApiMappingState == WireMockMappingState.NotSubmitted) { return HealthCheckResult.Unhealthy("WireMock.Net has not received mappings"); } diff --git a/src/WireMock.Net.Aspire/WireMockMappingState.cs b/src/WireMock.Net.Aspire/WireMockMappingState.cs new file mode 100644 index 000000000..2bb82108a --- /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 60c3f31d4..b19e192e2 100644 --- a/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs +++ b/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs @@ -183,7 +183,7 @@ public static IResourceBuilder WithApiMappingBuilder(thi wiremock.ApplicationBuilder.Services.TryAddLifecycleHook(); wiremock.Resource.Arguments.ApiMappingBuilder = configure; - wiremock.Resource.ApiMappingState = WireMockServerResource.MappingState.NotSubmitted; + wiremock.Resource.ApiMappingState = WireMockMappingState.NotSubmitted; return wiremock; } diff --git a/src/WireMock.Net.Aspire/WireMockServerResource.cs b/src/WireMock.Net.Aspire/WireMockServerResource.cs index 3fda524dd..528b21003 100644 --- a/src/WireMock.Net.Aspire/WireMockServerResource.cs +++ b/src/WireMock.Net.Aspire/WireMockServerResource.cs @@ -5,6 +5,7 @@ using Stef.Validation; using WireMock.Client; using WireMock.Client.Extensions; +using WireMock.Net.Aspire; using WireMock.Util; // ReSharper disable once CheckNamespace @@ -19,14 +20,7 @@ public class WireMockServerResource : ContainerResource, IResourceWithServiceDis internal WireMockServerArguments Arguments { get; } internal Lazy AdminApi => new(CreateWireMockAdminApi); - - internal enum MappingState - { - NoMappings, - NotSubmitted, - Submitted, - } - internal MappingState ApiMappingState { get; set; } = MappingState.NoMappings; + internal WireMockMappingState ApiMappingState { get; set; } = WireMockMappingState.NoMappings; private ILogger? _logger; private EnhancedFileSystemWatcher? _enhancedFileSystemWatcher; @@ -73,7 +67,7 @@ internal async Task CallApiMappingBuilderActionAsync(CancellationToken cancellat var mappingBuilder = AdminApi.Value.GetMappingBuilder(); await Arguments.ApiMappingBuilder.Invoke(mappingBuilder, cancellationToken); - ApiMappingState = MappingState.Submitted; + ApiMappingState = WireMockMappingState.Submitted; } internal void StartWatchingStaticMappings(CancellationToken cancellationToken) From 505f0a4767875fdc98d780a6b3b3270345313336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20H=C3=A4ggqvist?= Date: Mon, 17 Nov 2025 14:01:52 +0100 Subject: [PATCH 3/3] Dispose the cancellation tokens in WireMockServerLifecycleHook --- .../WireMockServerLifecycleHook.cs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs index c8f0c717f..57df9ee81 100644 --- a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs +++ b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs @@ -10,11 +10,12 @@ internal class WireMockServerLifecycleHook(ILoggerFactory loggerFactory) : IDist { private readonly CancellationTokenSource _shutdownCts = new(); + private CancellationTokenSource? _linkedCts; private Task? _mappingTask; - public async Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { - var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownCts.Token, cancellationToken); + _linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownCts.Token, cancellationToken); _mappingTask = Task.Run(async () => { @@ -29,19 +30,27 @@ public async Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appMo var endpoint = wireMockServerResource.GetEndpoint(); System.Diagnostics.Debug.Assert(endpoint.IsAllocated); - await wireMockServerResource.WaitForHealthAsync(cts.Token); + await wireMockServerResource.WaitForHealthAsync(_linkedCts.Token); - await wireMockServerResource.CallApiMappingBuilderActionAsync(cts.Token); + await wireMockServerResource.CallApiMappingBuilderActionAsync(_linkedCts.Token); - wireMockServerResource.StartWatchingStaticMappings(cts.Token); + wireMockServerResource.StartWatchingStaticMappings(_linkedCts.Token); } - }, cts.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