-
-
Notifications
You must be signed in to change notification settings - Fork 290
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; } | ||
|
||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
{ | ||
/// <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)))); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can use the UriBuilder here. If you create There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe you can pass the |
||
|
||
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); | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ISet<HttpStatusCode>
?