diff --git a/src/Testcontainers/Builders/HttpWaitRequest.cs b/src/Testcontainers/Builders/HttpWaitRequest.cs new file mode 100644 index 000000000..41b8bfc45 --- /dev/null +++ b/src/Testcontainers/Builders/HttpWaitRequest.cs @@ -0,0 +1,137 @@ +namespace DotNet.Testcontainers.Builders +{ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Net.Http; + using JetBrains.Annotations; + + /// + /// A http wait request. + /// + [PublicAPI] + public class HttpWaitRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The private container port. + /// The expected status codes. + /// The absolute path of the request uri. + /// The http method. + /// the http connection read timeout. + private HttpWaitRequest(int port, ISet statusCodes, string path, HttpMethod method, TimeSpan readTimeout) + { + this.Port = port; + this.StatusCodes = statusCodes; + this.Path = path; + this.Method = method; + this.ReadTimeout = readTimeout; + } + + public int Port { get; } + + public string Path { get; } + + public HttpMethod Method { get; } + + public ISet StatusCodes { get; } + + public TimeSpan ReadTimeout { get; } + + /// + /// Returns a new instance of wait request builder for a given port + /// + /// The private container port. + /// Instance of . + [PublicAPI] + public static Builder ForPort(int port) + { + return new Builder(port); + } + + /// + /// A fluent wait request builder. + /// + [SuppressMessage("ReSharper", "ParameterHidesMember", Justification = "Fluent builder")] + [PublicAPI] + public class Builder + { + private const string DefaultPath = "/"; + + private readonly int port; + private readonly ISet statusCodes = new HashSet(); + private string path = DefaultPath; + private HttpMethod method = HttpMethod.Get; + private TimeSpan readTimeout = TimeSpan.FromSeconds(1); + + internal Builder(int port) + { + this.port = port; + } + + /// + /// Waits for the given status code. + /// + /// The expected status code. + /// A configured instance of . + [PublicAPI] + public Builder ForStatusCode(int statusCode) + { + this.statusCodes.Add(statusCode); + return this; + } + + /// + /// Wait for the given path. + /// + /// The absolute path of the request uri. + /// A configured instance of . + [PublicAPI] + public Builder ForPath(string path) + { + this.path = string.IsNullOrWhiteSpace(path) ? DefaultPath : path.Trim(); + return this; + } + + /// + /// Indicates the HTTP method to use ( by default). + /// + /// The http method. + /// A configured instance of . + [PublicAPI] + public Builder WithMethod(HttpMethod method) + { + this.method = method; + return this; + } + + /// + /// Set the HTTP connections read timeout. + /// + /// The timeout. + /// A configured instance of . + [PublicAPI] + public Builder WithReadTimeout(TimeSpan timeout) + { + this.readTimeout = timeout; + return this; + } + + /// + /// Builds the instance of with the given configuration. + /// + /// A configured instance of . + [PublicAPI] + public HttpWaitRequest Build() + { + return new HttpWaitRequest( + this.port, + this.statusCodes, + this.path, + this.method, + this.readTimeout); + } + } + } +} diff --git a/src/Testcontainers/Configurations/WaitStrategies/ExternalWaitStrategyExtension.cs b/src/Testcontainers/Configurations/WaitStrategies/ExternalWaitStrategyExtension.cs new file mode 100644 index 000000000..0e6cac414 --- /dev/null +++ b/src/Testcontainers/Configurations/WaitStrategies/ExternalWaitStrategyExtension.cs @@ -0,0 +1,24 @@ +namespace DotNet.Testcontainers.Configurations +{ + using DotNet.Testcontainers.Builders; + using JetBrains.Annotations; + + public static class ExternalWaitStrategyExtension + { + /// + /// Waits until the http request is completed successfully. + /// + /// A configured instance of . + /// The http request to be executed. + ///The retry frequency in milliseconds. + /// The timeout in milliseconds. + /// A configured instance of . + /// Utilizes the HttpClient to send request from host. Expects either the response status code to match given status code(s) or a successful response. + [PublicAPI] + public static IWaitForContainerOS UntilHttpRequestIsCompleted(this IWaitForContainerOS waitForContainerOs, HttpWaitRequest request, int frequency = 25, int timeout = -1) + { + waitForContainerOs.AddCustomWaitStrategy(new UntilHttpRequestIsCompleted(request, frequency, timeout)); + return waitForContainerOs; + } + } +} diff --git a/src/Testcontainers/Configurations/WaitStrategies/UntilHttpRequestIsCompleted.cs b/src/Testcontainers/Configurations/WaitStrategies/UntilHttpRequestIsCompleted.cs new file mode 100644 index 000000000..8722379c3 --- /dev/null +++ b/src/Testcontainers/Configurations/WaitStrategies/UntilHttpRequestIsCompleted.cs @@ -0,0 +1,94 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Containers; + using Microsoft.Extensions.Logging; + + internal class UntilHttpRequestIsCompleted : IWaitUntil, IDisposable + { + private readonly HttpWaitRequest request; + private readonly int frequency; + private readonly int timeout; + private readonly HttpClientHandler handler; + private readonly bool disposeHandler; + + public UntilHttpRequestIsCompleted(HttpWaitRequest request, int frequency, int timeout) + : this(request, frequency, timeout, new HttpClientHandler(), true) + { + } + + public UntilHttpRequestIsCompleted(HttpWaitRequest request, int frequency, int timeout, HttpClientHandler handler, bool disposeHandler = false) + { + this.request = request; + this.handler = handler; + this.disposeHandler = disposeHandler; + this.frequency = frequency; + this.timeout = timeout; + } + + public async Task Until(ITestcontainersContainer testcontainers, ILogger logger) + { + var httpClient = new HttpClient(this.handler, false); + httpClient.Timeout = this.request.ReadTimeout; + + await WaitStrategy.WaitUntil( + async () => + { + try + { + var response = await httpClient.SendAsync( + new HttpRequestMessage( + this.request.Method, + this.BuildRequestUri(testcontainers.Hostname, testcontainers.GetMappedPublicPort(this.request.Port)))); + + if (this.request.StatusCodes.Any() && !this.request.StatusCodes.Contains((int)response.StatusCode)) + { + return false; + } + + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) + { + return false; + } + }, + this.frequency, + this.timeout, + CancellationToken.None); + + return true; + } + + public void Dispose() + { + if (this.disposeHandler) + { + this.handler?.Dispose(); + GC.SuppressFinalize(this); + } + } + + private Uri BuildRequestUri(string hostname, ushort port) + { + string portSuffix; + if (port == 80) + { + portSuffix = string.Empty; + } + else + { + portSuffix = ":" + port; + } + + var path = this.request.Path.StartsWith("/", StringComparison.OrdinalIgnoreCase) ? this.request.Path : "/" + this.request.Path; + + return new Uri($"http://{hostname}{portSuffix}{path}"); + } + } +} diff --git a/tests/Testcontainers.Tests/Unit/Builders/HttpWaitRequestBuilderTest.cs b/tests/Testcontainers.Tests/Unit/Builders/HttpWaitRequestBuilderTest.cs new file mode 100644 index 000000000..158734489 --- /dev/null +++ b/tests/Testcontainers.Tests/Unit/Builders/HttpWaitRequestBuilderTest.cs @@ -0,0 +1,33 @@ +namespace DotNet.Testcontainers.Tests.Unit +{ + using System.Net.Http; + using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Configurations; + using Moq; + using Xunit; + + public class HttpWaitRequestBuilderTest + { + private const int DefaultPort = 80; + private static readonly HttpWaitRequest WaitRequest = HttpWaitRequest.ForPort(DefaultPort).Build(); + + [Fact] + public void BuilderIncludeDefaults() + { + Assert.Equal("/", WaitRequest.Path); + Assert.Equal(HttpMethod.Get, WaitRequest.Method); + Assert.Empty(WaitRequest.StatusCodes); + Assert.Equal(1, WaitRequest.ReadTimeout.Seconds); + } + + [Fact] + public void WaitForHttpRequestAddsCustomWaitStrategy() + { + var container = new Mock(); + + container.Object.UntilHttpRequestIsCompleted(WaitRequest); + + container.Verify(c => c.AddCustomWaitStrategy(It.IsAny()), Times.Once); + } + } +} diff --git a/tests/Testcontainers.Tests/Unit/Configurations/WaitUntilHttpRequestIsCompletedTest.cs b/tests/Testcontainers.Tests/Unit/Configurations/WaitUntilHttpRequestIsCompletedTest.cs new file mode 100644 index 000000000..48da7cffc --- /dev/null +++ b/tests/Testcontainers.Tests/Unit/Configurations/WaitUntilHttpRequestIsCompletedTest.cs @@ -0,0 +1,97 @@ +namespace DotNet.Testcontainers.Tests.Unit +{ + using System; + using System.Collections.Generic; + using System.Net; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Configurations; + using DotNet.Testcontainers.Containers; + using Moq; + using Xunit; + + public sealed class WaitUntilHttpRequestIsCompletedTest + { + public static IEnumerable RequestData() + { + yield return new object[] { 80, HttpMethod.Head, string.Empty, 200, 200, false }; + yield return new object[] { 8080, HttpMethod.Head, "abs", 200, 200, false }; + yield return new object[] { 3000, HttpMethod.Options, "/", 200, 200, false }; + yield return new object[] { 5000, HttpMethod.Get, "/health", 204, 204, false }; + yield return new object[] { 8080, HttpMethod.Get, "/actuator/health", 200, 500, true }; + } + + [Theory] + [MemberData(nameof(RequestData))] + public async Task WaitUntilRequestIsCompleted(ushort port, HttpMethod method, string path, int expectedStatusCode, int resultStatusCode, bool shouldTimeout) + { + // Given + var waitRequestBuilder = HttpWaitRequest.ForPort(port) + .WithMethod(method) + .ForPath(path) + .ForStatusCode(expectedStatusCode); + + var callCounter = 0; + + HttpRequestMessage requestMessage = null; + var handler = new MockClientHandler((request, _) => + { + callCounter++; + + if (callCounter > 1) + { + throw new ArtificialTimeoutException(); + } + + requestMessage = request; + return Task.FromResult(new HttpResponseMessage((HttpStatusCode)resultStatusCode)); + }); + + const string hostname = "localhost"; + var testContainer = new Mock(); + testContainer.SetupGet(c => c.Hostname).Returns(hostname); + testContainer.Setup(c => c.GetMappedPublicPort(port)).Returns(port); + + var wait = new UntilHttpRequestIsCompleted(waitRequestBuilder.Build(), 0, -1, handler, true); + try + { + // When + await wait.Until(testContainer.Object, null); + + // Then + Assert.False(shouldTimeout); + Assert.NotNull(requestMessage?.RequestUri); + Assert.Equal(method, requestMessage.Method); + Assert.Equal(hostname, requestMessage.RequestUri!.Host); + Assert.Equal(port, requestMessage.RequestUri!.Port); + Assert.Contains(path, requestMessage.RequestUri!.AbsolutePath); + } + catch (TimeoutException) + { + // Then + Assert.True(shouldTimeout); + } + } + + private class ArtificialTimeoutException : TimeoutException + { + } + + private class MockClientHandler : HttpClientHandler + { + private readonly Func> handler; + + public MockClientHandler(Func> handler) + { + this.handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return this.handler(request, cancellationToken); + } + } + } +}