Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add http wait strategy #552

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/Testcontainers/Builders/HttpWaitRequest.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A http wait request.
/// </summary>
[PublicAPI]
public class HttpWaitRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="HttpWaitRequest" /> class.
/// </summary>
/// <param name="port">The private container port.</param>
/// <param name="statusCodes">The expected status codes.</param>
/// <param name="path">The absolute path of the request uri.</param>
/// <param name="method">The http method.</param>
/// <param name="readTimeout">the http connection read timeout.</param>
private HttpWaitRequest(int port, ISet<int> 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<int> StatusCodes { get; }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISet<HttpStatusCode>?


public TimeSpan ReadTimeout { get; }

/// <summary>
/// Returns a new instance of wait request builder for a given port
/// </summary>
/// <param name="port">The private container port.</param>
/// <returns>Instance of <see cref="HttpWaitRequest.Builder" />.</returns>
[PublicAPI]
public static Builder ForPort(int port)
{
return new Builder(port);
}

/// <summary>
/// A fluent wait request builder.
/// </summary>
[SuppressMessage("ReSharper", "ParameterHidesMember", Justification = "Fluent builder")]
[PublicAPI]
public class Builder
{
private const string DefaultPath = "/";

private readonly int port;
Copy link
Collaborator

@HofmeisterAn HofmeisterAn Aug 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use the UriBuilder here and store the values in the builder fields.

private readonly ISet<int> statusCodes = new HashSet<int>();
private string path = DefaultPath;
private HttpMethod method = HttpMethod.Get;
private TimeSpan readTimeout = TimeSpan.FromSeconds(1);

internal Builder(int port)
{
this.port = port;
}

/// <summary>
/// Waits for the given status code.
/// </summary>
/// <param name="statusCode">The expected status code.</param>
/// <returns>A configured instance of <see cref="Builder" />.</returns>
[PublicAPI]
public Builder ForStatusCode(int statusCode)
{
this.statusCodes.Add(statusCode);
return this;
}

/// <summary>
/// Wait for the given path.
/// </summary>
/// <param name="path">The absolute path of the request uri.</param>
/// <returns>A configured instance of <see cref="Builder" />.</returns>
[PublicAPI]
public Builder ForPath(string path)
{
this.path = string.IsNullOrWhiteSpace(path) ? DefaultPath : path.Trim();
return this;
}

/// <summary>
/// Indicates the HTTP method to use (<see cref="HttpMethod.Get" /> by default).
/// </summary>
/// <param name="method">The http method.</param>
/// <returns>A configured instance of <see cref="Builder" />.</returns>
[PublicAPI]
public Builder WithMethod(HttpMethod method)
{
this.method = method;
return this;
}

/// <summary>
/// Set the HTTP connections read timeout.
/// </summary>
/// <param name="timeout">The timeout.</param>
/// <returns>A configured instance of <see cref="Builder" />.</returns>
[PublicAPI]
public Builder WithReadTimeout(TimeSpan timeout)
{
this.readTimeout = timeout;
return this;
}

/// <summary>
/// Builds the instance of <see cref="HttpWaitRequest" /> with the given configuration.
/// </summary>
/// <returns>A configured instance of <see cref="HttpWaitRequest" />.</returns>
[PublicAPI]
public HttpWaitRequest Build()
{
return new HttpWaitRequest(
this.port,
this.statusCodes,
this.path,
this.method,
this.readTimeout);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace DotNet.Testcontainers.Configurations
{
using DotNet.Testcontainers.Builders;
using JetBrains.Annotations;

public static class ExternalWaitStrategyExtension
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why you choose an extension? I think we can put this into WaitForContainerOS.

{
/// <summary>
/// Waits until the http request is completed successfully.
/// </summary>
/// <param name="waitForContainerOs">A configured instance of <see cref="IWaitForContainerOS" />.</param>
/// <param name="request">The http request to be executed.</param>
///<param name="frequency">The retry frequency in milliseconds.</param>
/// <param name="timeout">The timeout in milliseconds.</param>
/// <returns>A configured instance of <see cref="IWaitForContainerOS" />.</returns>
/// <remarks>Utilizes the HttpClient to send request from host. Expects either the response status code to match given status code(s) or a successful response.</remarks>
[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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<bool> 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))));
Copy link
Collaborator

@HofmeisterAn HofmeisterAn Aug 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use the UriBuilder here. If you create HttpRequestMessage in the HttpWaitRequest you probably don't need it here at all.

Copy link
Collaborator

@HofmeisterAn HofmeisterAn Aug 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can pass the Hostname to something like HttpRequestMessage Build(string hostname, int mappedPublicPort). Or WithHostname(...). That the builder creates the HttpRequestMessage. Then you probably don't need the nested Builder class too.


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}");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<IWaitForContainerOS>();

container.Object.UntilHttpRequestIsCompleted(WaitRequest);

container.Verify(c => c.AddCustomWaitStrategy(It.IsAny<UntilHttpRequestIsCompleted>()), Times.Once);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<object[]> 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<ITestcontainersContainer>();
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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler;

public MockClientHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
this.handler = handler;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return this.handler(request, cancellationToken);
}
}
}
}