// Copyright © WireMock.Net using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; using JetBrains.Annotations; using Microsoft.Extensions.Logging; using RestEase; using Stef.Validation; using WireMock.Client; using WireMock.Client.Extensions; using WireMock.Http; using WireMock.Net.Testcontainers.Utils; using WireMock.Util; namespace WireMock.Net.Testcontainers; /// /// A container for running WireMock in a docker environment. /// /// /// Initializes a new instance of the class. /// /// The container configuration. public sealed class WireMockContainer(WireMockConfiguration configuration) : DockerContainer(configuration) { private const int EnhancedFileSystemWatcherTimeoutMs = 2000; internal const int ContainerPort = 80; private readonly WireMockConfiguration _configuration = Guard.NotNull(configuration); private IWireMockAdminApi? _adminApi; private EnhancedFileSystemWatcher? _enhancedFileSystemWatcher; private IDictionary? _publicUris; /// /// Gets the public Url. /// [PublicAPI] public string GetPublicUrl() => GetPublicUri().ToString(); /// /// Gets the public Urls as a dictionary with the internal port as the key. /// [PublicAPI] public IDictionary GetPublicUrls() => GetPublicUris().ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString()); /// /// Gets the mapped public port for the given container port. /// [PublicAPI] public string GetMappedPublicUrl(int containerPort) { return GetPublicUris()[containerPort].ToString(); } /// /// Create a RestEase Admin client which can be used to call the admin REST endpoint. /// /// A [PublicAPI] public IWireMockAdminApi CreateWireMockAdminClient() { ValidateIfRunning(); var api = RestClient.For(GetPublicUri()); return _configuration.HasBasicAuthentication ? api.WithAuthorization(_configuration.Username!, _configuration.Password!) : api; } /// /// Create a which can be used to call this instance. /// /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient /// to the network and an System.Net.Http.HttpResponseMessage travels from the network /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. /// That is, the first entry is invoked first for an outbound request message but /// last for an inbound response message. /// /// [PublicAPI] public HttpClient CreateClient(params DelegatingHandler[] handlers) { ValidateIfRunning(); var client = HttpClientFactory2.Create(handlers); client.BaseAddress = GetPublicUri(); return client; } /// /// Create a (one for each URL) which can be used to call this instance. /// The inner handler represents the destination of the HTTP message channel. /// /// An ordered list of System.Net.Http.DelegatingHandler instances to be invoked /// as an System.Net.Http.HttpRequestMessage travels from the System.Net.Http.HttpClient /// to the network and an System.Net.Http.HttpResponseMessage travels from the network /// back to System.Net.Http.HttpClient. The handlers are invoked in a top-down fashion. /// That is, the first entry is invoked first for an outbound request message but /// last for an inbound response message. /// /// [PublicAPI] public HttpClient CreateClient(HttpMessageHandler innerHandler, params DelegatingHandler[] handlers) { ValidateIfRunning(); var client = HttpClientFactory2.Create(innerHandler, handlers); client.BaseAddress = GetPublicUri(); return client; } /// /// Copies a test host directory or file to the container and triggers a reload of the static mappings if required. /// /// The source directory or file to be copied. /// The target directory path to copy the files to. /// The user ID to set for the copied file or directory. Defaults to 0 (root). /// The group ID to set for the copied file or directory. Defaults to 0 (root). /// The POSIX file mode permission. /// Cancellation token. /// A task that completes when the directory or file has been copied. public new async Task CopyAsync(string source, string target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644, CancellationToken ct = default) { await base.CopyAsync(source, target, uid, gid, fileMode, ct); if (_configuration.WatchStaticMappings && await PathStartsWithContainerMappingsPath(target)) { await ReloadStaticMappingsAsync(target, ct); } } /// /// Reload the static mappings. /// /// The optional cancellationToken. public async Task ReloadStaticMappingsAsync(CancellationToken cancellationToken = default) { if (_adminApi == null) { return; } try { var result = await _adminApi.ReloadStaticMappingsAsync(cancellationToken); Logger.LogInformation("WireMock.Net -> ReloadStaticMappings result: {Result}", result); } catch (Exception ex) { Logger.LogWarning(ex, "WireMock.Net -> Error calling /__admin/mappings/reloadStaticMappings"); } } /// /// Performs additional actions after the container is ready. /// public Task CallAdditionalActionsAfterReadyAsync() { Logger.LogInformation("WireMock.Net -> Calling additional actions."); _adminApi = CreateWireMockAdminClient(); RegisterEnhancedFileSystemWatcher(); return AddProtoDefinitionsAsync(); } /// protected override ValueTask DisposeAsyncCore() { if (_enhancedFileSystemWatcher != null) { _enhancedFileSystemWatcher.EnableRaisingEvents = false; _enhancedFileSystemWatcher.Created -= FileCreatedChangedOrDeleted; _enhancedFileSystemWatcher.Changed -= FileCreatedChangedOrDeleted; _enhancedFileSystemWatcher.Deleted -= FileCreatedChangedOrDeleted; _enhancedFileSystemWatcher.Dispose(); _enhancedFileSystemWatcher = null; } return base.DisposeAsyncCore(); } private static async Task PathStartsWithContainerMappingsPath(string value) { var imageOs = await TestcontainersUtils.GetImageOSAsync.Value; return value.StartsWith(ContainerInfoProvider.Info[imageOs].MappingsPath); } private void ValidateIfRunning() { if (State != TestcontainersStates.Running) { throw new InvalidOperationException("Unable to create HttpClient because the WireMock.Net is not yet running."); } } private void RegisterEnhancedFileSystemWatcher() { if (!_configuration.WatchStaticMappings || string.IsNullOrEmpty(_configuration.StaticMappingsPath)) { return; } _enhancedFileSystemWatcher = new EnhancedFileSystemWatcher(_configuration.StaticMappingsPath!, "*.json", EnhancedFileSystemWatcherTimeoutMs) { IncludeSubdirectories = _configuration.WatchStaticMappingsInSubdirectories }; _enhancedFileSystemWatcher.Created += FileCreatedChangedOrDeleted; _enhancedFileSystemWatcher.Changed += FileCreatedChangedOrDeleted; _enhancedFileSystemWatcher.Deleted += FileCreatedChangedOrDeleted; _enhancedFileSystemWatcher.EnableRaisingEvents = true; } private async Task AddProtoDefinitionsAsync() { foreach (var kvp in _configuration.ProtoDefinitions) { Logger.LogInformation("WireMock.Net -> Adding ProtoDefinition '{Id}'", kvp.Key); foreach (var protoDefinition in kvp.Value) { try { var result = await _adminApi!.AddProtoDefinitionAsync(kvp.Key, protoDefinition); Logger.LogInformation("WireMock.Net -> AddProtoDefinition '{Id}' result: {Result}", kvp.Key, result); } catch (Exception ex) { Logger.LogWarning(ex, "WireMock.Net -> Error adding ProtoDefinition '{Id}'.", kvp.Key); } } } // Force a reload of static mappings when ProtoDefinitions are added at server-level to fix #1382 if (_configuration.ProtoDefinitions.Count > 0) { await ReloadStaticMappingsAsync(); } } private async void FileCreatedChangedOrDeleted(object sender, FileSystemEventArgs args) { try { await ReloadStaticMappingsAsync(args.FullPath); Logger.LogInformation("WireMock.Net -> ReloadStaticMappings triggered from file change: '{FullPath}'.", args.FullPath); } catch (Exception ex) { Logger.LogWarning(ex, "WireMock.Net -> Error reloading static mappings from '{FullPath}'.", args.FullPath); } } private async Task ReloadStaticMappingsAsync(string path, CancellationToken cancellationToken = default) { Logger.LogInformation("WireMock.Net -> MappingFile created, changed or deleted: '{Path}'. Triggering ReloadStaticMappings.", path); await ReloadStaticMappingsAsync(cancellationToken); } private Uri GetPublicUri() => GetPublicUris()[ContainerPort]; private IDictionary GetPublicUris() { if (_publicUris != null) { return _publicUris; } _publicUris = _configuration.ExposedPorts.Keys .Select(int.Parse) .ToDictionary(port => port, port => new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(port)).Uri); foreach (var url in _configuration.AdditionalUrls) { if (PortUtils.TryExtract(url, out _, out _, out _, out _, out var port)) { _publicUris[port] = new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(port)).Uri; } } return _publicUris; } }