diff --git a/README.md b/README.md index 7840caef..4108dac3 100644 --- a/README.md +++ b/README.md @@ -68,13 +68,15 @@ WireMock.Net can be used in several ways: You can use your favorite test framework and use WireMock within your tests, see [Wiki : UnitTesting](https://github.com/StefH/WireMock.Net/wiki/Using-WireMock-in-UnitTests). +### Unit/Integration Testing using Testcontainers.DotNet +You can use [Wiki : WireMock.Net.Testcontainers](https://github.com/WireMock-Net/WireMock.Net/wiki/Using-WireMock.Net.Testcontainers) to build a WireMock.Net Docker container which can be used in Unit/Integration testing. + ### As a dotnet tool It's simple to install WireMock.Net as (global) dotnet tool, see [Wiki : dotnet tool](https://github.com/StefH/WireMock.Net/wiki/WireMock-as-dotnet-tool). ### As standalone process / console application This is quite straight forward to launch a mock server within a console application, see [Wiki : Standalone Process](https://github.com/StefH/WireMock.Net/wiki/WireMock-as-a-standalone-process). - ### As a Windows Service You can also run WireMock.Net as a Windows Service, follow this [WireMock-as-a-Windows-Service](https://github.com/WireMock-Net/WireMock.Net/wiki/WireMock-as-a-Windows-Service). diff --git a/WireMock.Net Solution.sln b/WireMock.Net Solution.sln index 6e07ee6c..9e767796 100644 --- a/WireMock.Net Solution.sln +++ b/WireMock.Net Solution.sln @@ -111,6 +111,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WireMockAzureQueueExample", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WireMockAzureQueueProxy", "examples\WireMockAzureQueueProxy\WireMockAzureQueueProxy.csproj", "{ADB557D8-D66B-4387-912B-3F73E290B478}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.Testcontainers", "src\WireMock.Net.Testcontainers\WireMock.Net.Testcontainers.csproj", "{12B016A5-9D8B-4EFE-96C2-CA51BE43367D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.TestcontainersExample", "examples\WireMock.Net.TestcontainersExample\WireMock.Net.TestcontainersExample.csproj", "{56A38798-C48B-4A4A-B805-071E05C02CE1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -265,6 +269,14 @@ Global {ADB557D8-D66B-4387-912B-3F73E290B478}.Debug|Any CPU.Build.0 = Debug|Any CPU {ADB557D8-D66B-4387-912B-3F73E290B478}.Release|Any CPU.ActiveCfg = Release|Any CPU {ADB557D8-D66B-4387-912B-3F73E290B478}.Release|Any CPU.Build.0 = Release|Any CPU + {12B016A5-9D8B-4EFE-96C2-CA51BE43367D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {12B016A5-9D8B-4EFE-96C2-CA51BE43367D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12B016A5-9D8B-4EFE-96C2-CA51BE43367D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {12B016A5-9D8B-4EFE-96C2-CA51BE43367D}.Release|Any CPU.Build.0 = Release|Any CPU + {56A38798-C48B-4A4A-B805-071E05C02CE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56A38798-C48B-4A4A-B805-071E05C02CE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56A38798-C48B-4A4A-B805-071E05C02CE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56A38798-C48B-4A4A-B805-071E05C02CE1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -309,6 +321,8 @@ Global {7C2A9DE8-C89F-4841-9058-6B9BF81E5E34} = {985E0ADB-D4B4-473A-AA40-567E279B7946} {BAA9EC2A-874B-45CE-8E51-A73622DC7F3D} = {985E0ADB-D4B4-473A-AA40-567E279B7946} {ADB557D8-D66B-4387-912B-3F73E290B478} = {985E0ADB-D4B4-473A-AA40-567E279B7946} + {12B016A5-9D8B-4EFE-96C2-CA51BE43367D} = {8F890C6F-9ACC-438D-928A-AD61CDA862F2} + {56A38798-C48B-4A4A-B805-071E05C02CE1} = {985E0ADB-D4B4-473A-AA40-567E279B7946} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC539027-9852-430C-B19F-FD035D018458} diff --git a/WireMock.Net Solution.sln.DotSettings b/WireMock.Net Solution.sln.DotSettings index 6f5a8ca1..c3564a0f 100644 --- a/WireMock.Net Solution.sln.DotSettings +++ b/WireMock.Net Solution.sln.DotSettings @@ -10,6 +10,7 @@ IP MD5 OPTIONS + OS PATCH POST PUT @@ -32,6 +33,7 @@ True True True + True True True True diff --git a/examples/WireMock.Net.TestcontainersExample/Program.cs b/examples/WireMock.Net.TestcontainersExample/Program.cs new file mode 100644 index 00000000..bf26c03d --- /dev/null +++ b/examples/WireMock.Net.TestcontainersExample/Program.cs @@ -0,0 +1,43 @@ +using Newtonsoft.Json; +using Testcontainers.MsSql; +using WireMock.Net.Testcontainers; + +namespace WireMock.Net.TestcontainersExample; + +internal class Program +{ + private static async Task Main(string[] args) + { + var container = new WireMockContainerBuilder() + .WithAdminUserNameAndPassword("x", "y") + .WithMappings(@"C:\Dev\GitHub\WireMock.Net\examples\WireMock.Net.Console.NET6\__admin\mappings") + .WithWatchStaticMappings(true) + .WithAutoRemove(true) + .WithCleanUp(true) + .Build(); + + await container.StartAsync().ConfigureAwait(false); + + var logs = await container.GetLogsAsync(DateTime.Now.AddDays(-1)).ConfigureAwait(false); + Console.WriteLine("logs = " + logs.Stdout); + + var restEaseApiClient = container.CreateWireMockAdminClient(); + + var settings = await restEaseApiClient.GetSettingsAsync(); + Console.WriteLine("settings = " + JsonConvert.SerializeObject(settings, Formatting.Indented)); + + var mappings = await restEaseApiClient.GetMappingsAsync(); + Console.WriteLine("mappings = " + JsonConvert.SerializeObject(mappings, Formatting.Indented)); + + var client = container.CreateClient(); + var result = await client.GetStringAsync("/static/mapping"); + Console.WriteLine("result = " + result); + + await container.StopAsync(); + + var sql = new MsSqlBuilder() + .WithAutoRemove(true) + .WithCleanUp(true) + .Build(); + } +} \ No newline at end of file diff --git a/examples/WireMock.Net.TestcontainersExample/WireMock.Net.TestcontainersExample.csproj b/examples/WireMock.Net.TestcontainersExample/WireMock.Net.TestcontainersExample.csproj new file mode 100644 index 00000000..14bf542e --- /dev/null +++ b/examples/WireMock.Net.TestcontainersExample/WireMock.Net.TestcontainersExample.csproj @@ -0,0 +1,24 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/WireMock.Net.Testcontainers/Models/ContainerInfo.cs b/src/WireMock.Net.Testcontainers/Models/ContainerInfo.cs new file mode 100644 index 00000000..f3011069 --- /dev/null +++ b/src/WireMock.Net.Testcontainers/Models/ContainerInfo.cs @@ -0,0 +1,7 @@ +namespace WireMock.Net.Testcontainers.Models; + +internal record ContainerInfo +( + string Image, + string MappingsPath +); \ No newline at end of file diff --git a/src/WireMock.Net.Testcontainers/WireMock.Net.Testcontainers.csproj b/src/WireMock.Net.Testcontainers/WireMock.Net.Testcontainers.csproj new file mode 100644 index 00000000..8c3dad81 --- /dev/null +++ b/src/WireMock.Net.Testcontainers/WireMock.Net.Testcontainers.csproj @@ -0,0 +1,36 @@ + + + + A fluent testcontainer builder for the Docker version of WireMock.Net + netstandard2.0;netstandard2.1 + true + wiremock;docker;testcontainer;testcontainers + {12B016A5-9D8B-4EFE-96C2-CA51BE43367D} + true + ../WireMock.Net/WireMock.Net.ruleset + true + ../WireMock.Net/WireMock.Net.snk + true + MIT + 10 + + + + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + \ No newline at end of file diff --git a/src/WireMock.Net.Testcontainers/WireMockConfiguration.cs b/src/WireMock.Net.Testcontainers/WireMockConfiguration.cs new file mode 100644 index 00000000..3403eca1 --- /dev/null +++ b/src/WireMock.Net.Testcontainers/WireMockConfiguration.cs @@ -0,0 +1,66 @@ +using Docker.DotNet.Models; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using JetBrains.Annotations; + +namespace WireMock.Net.Testcontainers; + +/// +[PublicAPI] +public sealed class WireMockConfiguration : ContainerConfiguration +{ +#pragma warning disable CS1591 + public string? Username { get; } + + public string? Password { get; } + + public bool HasBasicAuthentication => !string.IsNullOrEmpty(Username) && !string.IsNullOrEmpty(Password); + + public WireMockConfiguration( + string? username = null, + string? password = null + ) + { + Username = username; + Password = password; + } +#pragma warning restore CS1591 + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public WireMockConfiguration(IResourceConfiguration resourceConfiguration) : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public WireMockConfiguration(IContainerConfiguration resourceConfiguration) : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public WireMockConfiguration(WireMockConfiguration resourceConfiguration) : this(new WireMockConfiguration(), resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public WireMockConfiguration(WireMockConfiguration oldValue, WireMockConfiguration newValue) : base(oldValue, newValue) + { + Username = BuildConfiguration.Combine(oldValue.Username, newValue.Username); + Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password); + } +} \ No newline at end of file diff --git a/src/WireMock.Net.Testcontainers/WireMockContainer.cs b/src/WireMock.Net.Testcontainers/WireMockContainer.cs new file mode 100644 index 00000000..6532b07f --- /dev/null +++ b/src/WireMock.Net.Testcontainers/WireMockContainer.cs @@ -0,0 +1,110 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using DotNet.Testcontainers.Containers; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using RestEase; +using Stef.Validation; +using WireMock.Client; + +namespace WireMock.Net.Testcontainers; + +/// +/// A container for running WireMock in a docker environment. +/// +public sealed class WireMockContainer : DockerContainer +{ + internal const int ContainerPort = 80; + + private readonly WireMockConfiguration _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public WireMockContainer(WireMockConfiguration configuration, ILogger logger) : base(configuration, logger) + { + _configuration = Guard.NotNull(configuration); + } + + /// + /// Gets the public Url. + /// + [PublicAPI] + public string GetPublicUrl() => GetPublicUri().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()); + + if (_configuration.HasBasicAuthentication) + { + api.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_configuration.Username}:{_configuration.Password}"))); + } + + return 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 = HttpClientFactory.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 = HttpClientFactory.Create(innerHandler, handlers); + client.BaseAddress = GetPublicUri(); + return client; + } + + private void ValidateIfRunning() + { + if (State != TestcontainersStates.Running) + { + throw new InvalidOperationException("Unable to create HttpClient because the WireMock.Net is not yet running."); + } + } + + private Uri GetPublicUri() => new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(ContainerPort)).Uri; +} diff --git a/src/WireMock.Net.Testcontainers/WireMockContainerBuilder.cs b/src/WireMock.Net.Testcontainers/WireMockContainerBuilder.cs new file mode 100644 index 00000000..d724b0e6 --- /dev/null +++ b/src/WireMock.Net.Testcontainers/WireMockContainerBuilder.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Docker.DotNet.Models; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using JetBrains.Annotations; +using Stef.Validation; +using WireMock.Net.Testcontainers.Models; + +namespace WireMock.Net.Testcontainers; + +/// +/// An specific fluent Docker container builder for WireMock.Net +/// +public sealed class WireMockContainerBuilder : ContainerBuilder +{ + private readonly Dictionary _info = new() + { + { false, new ContainerInfo("sheyenrath/wiremock.net:latest", "/app/__admin/mappings") }, + { true, new ContainerInfo("sheyenrath/wiremock.net-windows:latest", @"c:\app\__admin\mappings") } + }; + + private const string DefaultLogger = "WireMockConsoleLogger"; + + private readonly Lazy> _isWindowsAsLazy = new(async () => + { + using var dockerClientConfig = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientConfiguration(); + using var dockerClient = dockerClientConfig.CreateClient(); + + var version = await dockerClient.System.GetVersionAsync(); + return version.Os.IndexOf("Windows", StringComparison.OrdinalIgnoreCase) > -1; + }); + + /// + /// Initializes a new instance of the class. + /// + public WireMockContainerBuilder() : this(new WireMockConfiguration()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + /// + /// Automatically set the correct image (Linux or Windows) for WireMock which to create the container. + /// + /// A configured instance of + [PublicAPI] + public WireMockContainerBuilder WithImage() + { + var isWindows = _isWindowsAsLazy.Value.GetAwaiter().GetResult(); + return WithImage(_info[isWindows].Image); + } + + /// + /// Set the admin username and password for the container (basic authentication). + /// + /// The admin username. + /// The admin password. + /// A configured instance of + public WireMockContainerBuilder WithAdminUserNameAndPassword(string username, string password) + { + Guard.NotNull(username); + Guard.NotNull(password); + + if (string.IsNullOrEmpty(username) && string.IsNullOrEmpty(password)) + { + return this; + } + + return Merge(DockerResourceConfiguration, new WireMockConfiguration(username, password)) + .WithCommand($"--AdminUserName {username}", $"--AdminPassword {password}"); + } + + /// + /// Use the WireMockNullLogger. + /// + /// A configured instance of + [PublicAPI] + public WireMockContainerBuilder WithNullLogger() + { + return WithCommand("--WireMockLogger WireMockNullLogger"); + } + + /// + /// Defines if the static mappings should be read at startup (default set to false). + /// + /// A configured instance of + [PublicAPI] + public WireMockContainerBuilder WithReadStaticMappings() + { + return WithCommand("--ReadStaticMappings true"); + } + + /// + /// Watch the static mapping files + folder for changes when running. + /// + /// A configured instance of + [PublicAPI] + public WireMockContainerBuilder WithWatchStaticMappings(bool includeSubDirectories) + { + return WithCommand("--WatchStaticMappings true").WithCommand($"--WatchStaticMappingsInSubdirectories {includeSubDirectories}"); + } + + /// + /// Specifies the path for the (static) mapping json files. + /// + /// The path + /// + [PublicAPI] + public WireMockContainerBuilder WithMappings(string path) + { + Guard.NotNullOrEmpty(path); + + var isWindows = _isWindowsAsLazy.Value.GetAwaiter().GetResult(); + + return WithReadStaticMappings().WithBindMount(path, _info[isWindows].MappingsPath); + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private WireMockContainerBuilder(WireMockConfiguration dockerResourceConfiguration) : base(dockerResourceConfiguration) + { + DockerResourceConfiguration = dockerResourceConfiguration; + } + + /// + protected override WireMockConfiguration DockerResourceConfiguration { get; } + + /// + public override WireMockContainer Build() + { + Validate(); + + return new WireMockContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + } + + /// + protected override WireMockContainerBuilder Init() + { + var builder = base.Init(); + + // In case no image has been set, set the image using internal logic. + if (builder.DockerResourceConfiguration.Image == null) + { + builder = builder.WithImage(); + } + + var isWindows = _isWindowsAsLazy.Value.GetAwaiter().GetResult(); + var waitForContainerOS = isWindows ? Wait.ForWindowsContainer() : Wait.ForUnixContainer(); + return builder + .WithPortBinding(WireMockContainer.ContainerPort, true) + .WithCommand($"--WireMockLogger {DefaultLogger}") + .WithWaitStrategy(waitForContainerOS.UntilMessageIsLogged("By Stef Heyenrath")); + } + + /// + protected override WireMockContainerBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new WireMockConfiguration(resourceConfiguration)); + } + + /// + protected override WireMockContainerBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new WireMockConfiguration(resourceConfiguration)); + } + + /// + protected override WireMockContainerBuilder Merge(WireMockConfiguration oldValue, WireMockConfiguration newValue) + { + return new WireMockContainerBuilder(new WireMockConfiguration(oldValue, newValue)); + } +} \ No newline at end of file