From a37cbb563a49a0ad96ee3489501280756518a3d1 Mon Sep 17 00:00:00 2001 From: Martijn Bodeman <11424653+skwasjer@users.noreply.github.com> Date: Sun, 24 Dec 2023 14:28:34 +0100 Subject: [PATCH] feat: adds transfer rate extension (#96) * feat: instead of trying to insert preferred behaviors at the top of the behavior list, use a comparer to resort the behaviors. This is more reliable in case of custom extensions messing with the behavior list. * feat: adds TransferRate extension that wraps the response content to introduce artificial slower responses --- .../Extensions/ResponseBuilderExtensions.cs | 83 ++++++++-- .../Language/Flow/Response/ResponseBuilder.cs | 52 +++++- src/MockHttp/Responses/BitRate.cs | 149 ++++++++++++++++++ .../Responses/TransferRateBehavior.cs | 65 ++++++++ .../Flow/Response/NullBuilderTests.cs | 15 ++ .../Flow/Response/TransferRateSpec.cs | 19 ++- .../Flow/Response/TransferRateSpec2.cs | 15 ++ .../TransferRateWithMultipleRequestsSpec.cs | 18 +++ .../TransferRateWithOutOfRangeBitRateSpec.cs | 20 +++ test/MockHttp.Tests/MockHttpHandlerTests.cs | 1 + test/MockHttp.Tests/Responses/BitRateTests.cs | 113 +++++++++++++ 11 files changed, 533 insertions(+), 17 deletions(-) create mode 100644 src/MockHttp/Responses/BitRate.cs create mode 100644 src/MockHttp/Responses/TransferRateBehavior.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec2.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/TransferRateWithMultipleRequestsSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/TransferRateWithOutOfRangeBitRateSpec.cs create mode 100644 test/MockHttp.Tests/Responses/BitRateTests.cs diff --git a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs index b1937b48..3bcff83f 100644 --- a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs +++ b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs @@ -418,15 +418,7 @@ public static IWithResponse Latency(this IWithResponse builder, NetworkLatency l throw new ArgumentNullException(nameof(latency)); } - int existingIndex = builder.Behaviors.IndexOf(typeof(NetworkLatencyBehavior)); - if (existingIndex == -1) - { - builder.Behaviors.Insert(0, new NetworkLatencyBehavior(latency)); - } - else - { - builder.Behaviors[existingIndex] = new NetworkLatencyBehavior(latency); - } + builder.Behaviors.Replace(new NetworkLatencyBehavior(latency)); return builder; } @@ -447,6 +439,79 @@ public static IWithResponse Latency(this IWithResponse builder, Func + /// Limits the response to a specific bit rate to simulate slow(er) network transfer rates. + /// + /// The content stream returned in the response is throttled to match the requested bit rate. + /// + /// + /// + /// - Not 100% accurate (just like real world :p). + /// + /// The builder. + /// The bit rate to simulate. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + /// Thrown when the bit rate is less than 128. + public static IWithResponse TransferRate(this IWithResponse builder, Func bitRate) + { + if (bitRate is null) + { + throw new ArgumentNullException(nameof(bitRate)); + } + + return builder.TransferRate(bitRate()); + } + + /// + /// Limits the response to a specific bit rate to simulate slow(er) network transfer rates. + /// + /// The content stream returned in the response is throttled to match the requested bit rate. + /// + /// + /// + /// - Not 100% accurate (just like real world :p). + /// + /// The builder. + /// The bit rate to simulate. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + /// Thrown when the bit rate is less than 128. + public static IWithResponse TransferRate(this IWithResponse builder, BitRate bitRate) + { + if (bitRate is null) + { + throw new ArgumentNullException(nameof(bitRate)); + } + + return builder.TransferRate((int)bitRate); + } + + /// + /// Limits the response to a specific bit rate to simulate slow(er) network transfer rates. + /// + /// The content stream returned in the response is throttled to match the requested bit rate. + /// + /// + /// + /// - Not 100% accurate (just like real world :p). + /// + /// The builder. + /// The bit rate to simulate. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + /// Thrown when the bit rate is less than 128. + public static IWithResponse TransferRate(this IWithResponse builder, int bitRate) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Behaviors.Replace(new TransferRateBehavior(bitRate)); + return builder; + } + private static string? ConvertToString(T v) { switch (v) diff --git a/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs index 28aa2223..e72d68c0 100644 --- a/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs +++ b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs @@ -1,7 +1,6 @@ using System.Collections.ObjectModel; using System.Net; using System.Net.Http.Headers; -using System.Text; using MockHttp.Http; using MockHttp.Responses; @@ -80,6 +79,7 @@ public ResponseStrategy(IEnumerable behaviors) { _invertedBehaviors = new ReadOnlyCollection( behaviors + .OrderBy(behavior => behavior, new PreferredBehaviorComparer()) .Reverse() .ToList() ); @@ -105,6 +105,56 @@ await _invertedBehaviors return response; } + + /// + /// Some behaviors must be sorted at the top of the list, but may have been added out of order using the Fluent API. + /// This comparer shifts those preferred behaviors back to the top. + /// + private sealed class PreferredBehaviorComparer : IComparer + { + public int Compare(IResponseBehavior? x, IResponseBehavior? y) + { + return Compare(x, y, false); + } + + private static int Compare(IResponseBehavior? x, IResponseBehavior? y, bool flipped) + { + if (ReferenceEquals(x, null)) + { + return 1; + } + + if (ReferenceEquals(y, null)) + { + return -1; + } + + if (ReferenceEquals(x, y)) + { + return 0; + } + + return x switch + { + // The network latency behavior must always come first. + NetworkLatencyBehavior => -1, + // The rate limit behavior must always come first except when the latency behavior is also present. + TransferRateBehavior => y is NetworkLatencyBehavior + ? 1 + : CompareOtherWayAround(-1), + _ => CompareOtherWayAround(0) + }; + + int CompareOtherWayAround(int result) + { + return flipped + ? result +#pragma warning disable S2234 // Parameters to 'Compare' have the same names but not the same order as the method arguments. - justification: intentional. + : -Compare(y, x, true); +#pragma warning restore S2234 + } + } + } } } diff --git a/src/MockHttp/Responses/BitRate.cs b/src/MockHttp/Responses/BitRate.cs new file mode 100644 index 00000000..252eb1ef --- /dev/null +++ b/src/MockHttp/Responses/BitRate.cs @@ -0,0 +1,149 @@ +using System.Globalization; +using MockHttp.IO; + +// ReSharper disable once CheckNamespace +namespace MockHttp; + +/// +/// Defines different types of bit rates to simulate a slow network. +/// +public sealed class BitRate +{ + private readonly Func _factory; + private readonly string _name; + + private BitRate(Func factory, string name) + { + _factory = factory; + _name = name; + } + + /// + public override string ToString() + { + return $"{GetType().Name}.{_name}"; + } + + /// + /// 2G (mobile network) bit rate. (~64kbps). + /// + public static BitRate TwoG() + { + return Create(64_000, nameof(TwoG)); + } + + /// + /// 3G (mobile network) bit rate. (~2Mbps) + /// + public static BitRate ThreeG() + { + return Create(2_000_000, nameof(ThreeG)); + } + + /// + /// 4G (mobile network) bit rate. (~64Mbps) + /// + public static BitRate FourG() + { + return Create(64_000_000, nameof(FourG)); + } + + /// + /// 5G (mobile network) bit rate. (~512Mbps) + /// + public static BitRate FiveG() + { + return Create(512_000_000, nameof(FiveG)); + } + + /// + /// 10 Mbps. + /// + public static BitRate TenMegabit() + { + return Create(10_000_000, nameof(TenMegabit)); + } + + /// + /// 100 Mbps. + /// + public static BitRate OneHundredMegabit() + { + return Create(100_000_000, nameof(OneHundredMegabit)); + } + + /// + /// 1 Gbps. + /// + public static BitRate OneGigabit() + { + return Create(1_000_000_000, nameof(OneGigabit)); + } + + /// + /// Converts a bit rate to an integer representing the bit rate in bits per second. + /// + /// + /// + public static explicit operator int (BitRate bitRate) + { + return ToInt32(bitRate); + } + + /// + /// Converts a bit rate to an integer representing the bit rate in bits per second. + /// + /// + /// + public static explicit operator BitRate (int bitRate) + { + return FromInt32(bitRate); + } + + /// + /// Converts a bit rate to an integer representing the bit rate in bits per second. + /// + /// The bit rate. + /// The underlying bit rate value. + public static int ToInt32(BitRate bitRate) + { + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + return bitRate?._factory() ?? -1; + } + + /// + /// Convert an integer bit rate (in bits per second) to a . + /// + /// The bit rate. + /// The bit rate. + public static BitRate FromInt32(int bitRate) + { + return Create(bitRate, FormatBps(bitRate)); + } + + private static BitRate Create(int bitRate, string name) + { + if (bitRate <= 0) + { + throw new ArgumentOutOfRangeException(nameof(bitRate)); + } + + return new BitRate(() => bitRate, name); + } + + private static string FormatBps(long value) + { + return BpsToString().ToString(CultureInfo.InvariantCulture); + + FormattableString BpsToString() + { + return value switch + { + < 1_000 => $"Around({value}bps)", + < 1_000_000 => $"Around({(double)value / 1_000:#.##}kbps)", + < 1_000_000_000 => $"Around({(double)value / 1_000_000:#.##}Mbps)", + _ => $"Around({(double)value / 1_000_000_000:#.##}Gbps)" + }; + } + } +} diff --git a/src/MockHttp/Responses/TransferRateBehavior.cs b/src/MockHttp/Responses/TransferRateBehavior.cs new file mode 100644 index 00000000..94a3d9b2 --- /dev/null +++ b/src/MockHttp/Responses/TransferRateBehavior.cs @@ -0,0 +1,65 @@ +using System.Net; +using MockHttp.IO; + +namespace MockHttp.Responses; + +internal sealed class TransferRateBehavior : IResponseBehavior +{ + private readonly int _bitRate; + + public TransferRateBehavior(int bitRate) + { + if (bitRate < RateLimitedStream.MinBitRate) + { + throw new ArgumentOutOfRangeException(nameof(bitRate), $"Bit rate must be higher than or equal to {RateLimitedStream.MinBitRate}."); + } + + _bitRate = bitRate; + } + + public async Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, ResponseHandlerDelegate next, CancellationToken cancellationToken) + { + await next(requestContext, responseMessage, cancellationToken).ConfigureAwait(false); + responseMessage.Content = new RateLimitedHttpContent(responseMessage.Content, _bitRate); + } + + private sealed class RateLimitedHttpContent : HttpContent + { + private readonly int _bitRate; + private readonly HttpContent _originalContent; + + internal RateLimitedHttpContent(HttpContent originalContent, int bitRate) + { + _originalContent = originalContent; + _bitRate = bitRate; + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + Stream originalStream = await _originalContent.ReadAsStreamAsync().ConfigureAwait(false); + var rateLimitedStream = new RateLimitedStream(originalStream, _bitRate); +#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER + await using (originalStream) + await using (rateLimitedStream) +#else + using (originalStream) + using (rateLimitedStream) +#endif + { + await rateLimitedStream.CopyToAsync(stream).ConfigureAwait(false); + } + } + + protected override bool TryComputeLength(out long length) + { + long? contentLength = _originalContent.Headers.ContentLength; + length = 0; + if (contentLength.HasValue) + { + length = contentLength.Value; + } + + return contentLength.HasValue; + } + } +} diff --git a/test/MockHttp.Tests/Language/Flow/Response/NullBuilderTests.cs b/test/MockHttp.Tests/Language/Flow/Response/NullBuilderTests.cs index 12e6ac26..5c11f882 100644 --- a/test/MockHttp.Tests/Language/Flow/Response/NullBuilderTests.cs +++ b/test/MockHttp.Tests/Language/Flow/Response/NullBuilderTests.cs @@ -141,6 +141,21 @@ public void Given_null_argument_when_executing_method_it_should_throw(params obj responseBuilder, new XDocument(), (XmlWriterSettings?)null + ), + DelegateTestCase.Create( + ResponseBuilderExtensions.TransferRate, + responseBuilder, + BitRate.FourG() + ), + DelegateTestCase.Create( + ResponseBuilderExtensions.TransferRate, + responseBuilder, + BitRate.FourG + ), + DelegateTestCase.Create( + ResponseBuilderExtensions.TransferRate, + responseBuilder, + (int)BitRate.FourG() ) }; diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs index a7335660..30986e52 100644 --- a/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs @@ -5,30 +5,35 @@ namespace MockHttp.Language.Flow.Response; -public sealed class TransferRateSpec : ResponseSpec +public class TransferRateSpec : ResponseSpec { private const int DataSizeInBytes = 256 * 1024; // 256 KB - private const int BitRate = 512000; // 512 kbps = 64 KB/s + protected const int BitRate = 1_024_000; // 1024 kbps = 128 KB/s private static readonly TimeSpan ExpectedTotalTime = TimeSpan.FromSeconds(DataSizeInBytes / ((double)BitRate / 8)); - private readonly byte[] _content = Enumerable.Range(0, DataSizeInBytes) + protected readonly byte[] Content = Enumerable.Range(0, DataSizeInBytes) .Select((_, index) => (byte)(index % 256)) .ToArray(); private readonly Stopwatch _stopwatch = new(); + protected override Task When(HttpClient httpClient) + { + _stopwatch.Restart(); + return base.When(httpClient); + } + protected override void Given(IResponseBuilder with) { - with.Body(new RateLimitedStream(new MemoryStream(_content), BitRate)); - _stopwatch.Start(); + with.Body(new RateLimitedStream(new MemoryStream(Content), BitRate)); } - protected override async Task Should(HttpResponseMessage response) + protected sealed override async Task Should(HttpResponseMessage response) { _stopwatch.Stop(); _stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(ExpectedTotalTime); response.Should().HaveStatusCode(HttpStatusCode.OK); byte[]? responseContent = await response.Content.ReadAsByteArrayAsync(); - responseContent.Should().BeEquivalentTo(_content, opts => opts.WithStrictOrdering()); + responseContent.Should().BeEquivalentTo(Content, opts => opts.WithStrictOrdering()); } } diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec2.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec2.cs new file mode 100644 index 00000000..acb80041 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec2.cs @@ -0,0 +1,15 @@ +using static MockHttp.BitRate; +using static MockHttp.NetworkLatency; + +namespace MockHttp.Language.Flow.Response; + +public class TransferRateSpec2 : TransferRateSpec +{ + protected override void Given(IResponseBuilder with) + { + with + .Body(Content) + .Latency(FourG) + .TransferRate(() => FromInt32(BitRate)); + } +} diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithMultipleRequestsSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithMultipleRequestsSpec.cs new file mode 100644 index 00000000..5ddadbbe --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithMultipleRequestsSpec.cs @@ -0,0 +1,18 @@ +namespace MockHttp.Language.Flow.Response; + +public class TransferRateWithMultipleRequestsSpec : TransferRateSpec2 +{ + protected override async Task When(HttpClient httpClient) + { + // Perform 5 requests in parallel. + HttpResponseMessage[] result = await Task.WhenAll( + base.When(httpClient), + base.When(httpClient), + base.When(httpClient), + base.When(httpClient), + base.When(httpClient) + ); + + return result.Last(); + } +} diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithOutOfRangeBitRateSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithOutOfRangeBitRateSpec.cs new file mode 100644 index 00000000..63232415 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateWithOutOfRangeBitRateSpec.cs @@ -0,0 +1,20 @@ +using MockHttp.IO; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public sealed class TransferRateWithOutOfRangeBitRateSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body(Array.Empty()) + .TransferRate(RateLimitedStream.MinBitRate - 1); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("bitRate"); + } +} diff --git a/test/MockHttp.Tests/MockHttpHandlerTests.cs b/test/MockHttp.Tests/MockHttpHandlerTests.cs index bce5bae8..bef966b0 100644 --- a/test/MockHttp.Tests/MockHttpHandlerTests.cs +++ b/test/MockHttp.Tests/MockHttpHandlerTests.cs @@ -376,6 +376,7 @@ public async Task Given_a_request_expectation_when_sending_requests_it_should_co .Respond(with => with .StatusCode(HttpStatusCode.Accepted) .Body(JsonConvert.SerializeObject(new { firstName = "John", lastName = "Doe" })) + .TransferRate(BitRate.TwoG) .Latency(NetworkLatency.TwoG) ) .Verifiable(); diff --git a/test/MockHttp.Tests/Responses/BitRateTests.cs b/test/MockHttp.Tests/Responses/BitRateTests.cs new file mode 100644 index 00000000..3cf2aba2 --- /dev/null +++ b/test/MockHttp.Tests/Responses/BitRateTests.cs @@ -0,0 +1,113 @@ +using static MockHttp.BitRate; + +namespace MockHttp.Responses; + +public sealed class BitRateTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Given_that_value_is_invalid_when_casting_it_should_throw(int bitRate) + { + // ACt + Func act = () => (BitRate)bitRate; + + // Assert + act.Should() + .Throw() + .WithParameterName(nameof(bitRate)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Given_that_value_is_invalid_when_converting_it_should_throw(int bitRate) + { + // ACt + Func act = () => FromInt32(bitRate); + + // Assert + act.Should() + .Throw() + .WithParameterName(nameof(bitRate)); + } + + [Theory] + [InlineData(1_000)] + [InlineData(1_234_567)] + public void When_converting_to_int_it_should_return_expected(int bitRate) + { + BitRate sut = FromInt32(bitRate); + + // Act + int actual = ToInt32(sut); + + // Assert + actual.Should().Be(bitRate); + } + + [Theory] + [InlineData(1_000)] + [InlineData(1_234_567)] + public void When_casting_to_int_it_should_return_expected(int bitRate) + { + BitRate sut = FromInt32(bitRate); + + // Act + int actual = (int)sut; + + // Assert + actual.Should().Be(bitRate); + } + + [Theory] + [MemberData(nameof(GetBitRateTestCases))] + public void When_creating_using_builtin_factories_it_should_return_expected(BitRate bitRate, int expectedBitRate) + { + ToInt32(bitRate).Should().Be(expectedBitRate); + } + + public static IEnumerable GetBitRateTestCases() + { + yield return new object[] { TwoG(), 64_000 }; + yield return new object[] { ThreeG(), 2_000_000 }; + yield return new object[] { FourG(), 64_000_000 }; + yield return new object[] { FiveG(), 512_000_000 }; + yield return new object[] { TenMegabit(), 10_000_000 }; + yield return new object[] { OneHundredMegabit(), 100_000_000 }; + yield return new object[] { OneGigabit(), 1_000_000_000 }; + yield return new object[] { FromInt32(999), 999 }; + yield return new object[] { FromInt32(1_000), 1_000 }; + yield return new object[] { FromInt32(1_234), 1_234 }; + yield return new object[] { FromInt32(1_000_000), 1_000_000 }; + yield return new object[] { FromInt32(1_234_567), 1_234_567 }; + yield return new object[] { FromInt32(1_000_000_000), 1_000_000_000 }; + yield return new object[] { FromInt32(1_234_567_890), 1_234_567_890 }; + } + + [Theory] + [MemberData(nameof(GetBitRatePrettyTextTestCases))] + public void When_formatting_it_should_return_expected(BitRate bitRate, string expectedPrettyText) + { + bitRate.ToString().Should().Be(expectedPrettyText); + } + + public static IEnumerable GetBitRatePrettyTextTestCases() + { + const string prefix = nameof(BitRate) + "."; + yield return new object[] { TwoG(), prefix + nameof(TwoG) }; + yield return new object[] { ThreeG(), prefix + nameof(ThreeG) }; + yield return new object[] { FourG(), prefix + nameof(FourG) }; + yield return new object[] { FiveG(), prefix + nameof(FiveG) }; + yield return new object[] { TenMegabit(), prefix + nameof(TenMegabit) }; + yield return new object[] { OneHundredMegabit(), prefix + nameof(OneHundredMegabit) }; + yield return new object[] { OneGigabit(), prefix + nameof(OneGigabit) }; + yield return new object[] { FromInt32(999), prefix + "Around(999bps)" }; + yield return new object[] { FromInt32(1_000), prefix + "Around(1kbps)" }; + yield return new object[] { FromInt32(1_234), prefix + "Around(1.23kbps)" }; + yield return new object[] { FromInt32(1_000_000), prefix + "Around(1Mbps)" }; + yield return new object[] { FromInt32(1_234_567), prefix + "Around(1.23Mbps)" }; + yield return new object[] { FromInt32(1_000_000_000), prefix + "Around(1Gbps)" }; + yield return new object[] { FromInt32(1_234_567_890), prefix + "Around(1.23Gbps)" }; + } +}