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: adds transfer rate extension #96

Merged
merged 2 commits into from
Dec 24, 2023
Merged
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
83 changes: 74 additions & 9 deletions src/MockHttp/Extensions/ResponseBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -447,6 +439,79 @@ public static IWithResponse Latency(this IWithResponse builder, Func<NetworkLate
return builder.Latency(latency());
}

/// <summary>
/// Limits the response to a specific bit rate to simulate slow(er) network transfer rates.
/// <para>
/// The content stream returned in the response is throttled to match the requested bit rate.
/// </para>
/// </summary>
/// <remarks>
/// - Not 100% accurate (just like real world :p).
/// </remarks>
/// <param name="builder">The builder.</param>
/// <param name="bitRate">The bit rate to simulate.</param>
/// <returns>The builder to continue chaining additional behaviors.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder" /> or <paramref name="bitRate" /> is <see langword="null" />.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the bit rate is less than 128.</exception>
public static IWithResponse TransferRate(this IWithResponse builder, Func<BitRate> bitRate)
{
if (bitRate is null)
{
throw new ArgumentNullException(nameof(bitRate));
}

return builder.TransferRate(bitRate());
}

/// <summary>
/// Limits the response to a specific bit rate to simulate slow(er) network transfer rates.
/// <para>
/// The content stream returned in the response is throttled to match the requested bit rate.
/// </para>
/// </summary>
/// <remarks>
/// - Not 100% accurate (just like real world :p).
/// </remarks>
/// <param name="builder">The builder.</param>
/// <param name="bitRate">The bit rate to simulate.</param>
/// <returns>The builder to continue chaining additional behaviors.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder" /> is <see langword="null" />.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the bit rate is less than 128.</exception>
public static IWithResponse TransferRate(this IWithResponse builder, BitRate bitRate)
{
if (bitRate is null)
{
throw new ArgumentNullException(nameof(bitRate));
}

return builder.TransferRate((int)bitRate);
}

/// <summary>
/// Limits the response to a specific bit rate to simulate slow(er) network transfer rates.
/// <para>
/// The content stream returned in the response is throttled to match the requested bit rate.
/// </para>
/// </summary>
/// <remarks>
/// - Not 100% accurate (just like real world :p).
/// </remarks>
/// <param name="builder">The builder.</param>
/// <param name="bitRate">The bit rate to simulate.</param>
/// <returns>The builder to continue chaining additional behaviors.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder" /> or <paramref name="bitRate" /> is <see langword="null" />.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the bit rate is less than 128.</exception>
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>(T v)
{
switch (v)
Expand Down
52 changes: 51 additions & 1 deletion src/MockHttp/Language/Flow/Response/ResponseBuilder.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -80,6 +79,7 @@ public ResponseStrategy(IEnumerable<IResponseBehavior> behaviors)
{
_invertedBehaviors = new ReadOnlyCollection<IResponseBehavior>(
behaviors
.OrderBy(behavior => behavior, new PreferredBehaviorComparer())
.Reverse()
.ToList()
);
Expand All @@ -105,6 +105,56 @@ await _invertedBehaviors

return response;
}

/// <summary>
/// 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.
/// </summary>
private sealed class PreferredBehaviorComparer : IComparer<IResponseBehavior>
{
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
}
}
}
}
}

149 changes: 149 additions & 0 deletions src/MockHttp/Responses/BitRate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using System.Globalization;
using MockHttp.IO;

// ReSharper disable once CheckNamespace
namespace MockHttp;

/// <summary>
/// Defines different types of bit rates to simulate a slow network.
/// </summary>
public sealed class BitRate
{
private readonly Func<int> _factory;
private readonly string _name;

private BitRate(Func<int> factory, string name)
{
_factory = factory;
_name = name;
}

/// <inheritdoc />
public override string ToString()
{
return $"{GetType().Name}.{_name}";
}

/// <summary>
/// 2G (mobile network) bit rate. (~64kbps).
/// </summary>
public static BitRate TwoG()
{
return Create(64_000, nameof(TwoG));
}

/// <summary>
/// 3G (mobile network) bit rate. (~2Mbps)
/// </summary>
public static BitRate ThreeG()
{
return Create(2_000_000, nameof(ThreeG));
}

/// <summary>
/// 4G (mobile network) bit rate. (~64Mbps)
/// </summary>
public static BitRate FourG()
{
return Create(64_000_000, nameof(FourG));
}

/// <summary>
/// 5G (mobile network) bit rate. (~512Mbps)
/// </summary>
public static BitRate FiveG()
{
return Create(512_000_000, nameof(FiveG));
}

/// <summary>
/// 10 Mbps.
/// </summary>
public static BitRate TenMegabit()
{
return Create(10_000_000, nameof(TenMegabit));
}

/// <summary>
/// 100 Mbps.
/// </summary>
public static BitRate OneHundredMegabit()
{
return Create(100_000_000, nameof(OneHundredMegabit));
}

/// <summary>
/// 1 Gbps.
/// </summary>
public static BitRate OneGigabit()
{
return Create(1_000_000_000, nameof(OneGigabit));
}

/// <summary>
/// Converts a bit rate to an integer representing the bit rate in bits per second.
/// </summary>
/// <param name="bitRate"></param>
/// <returns></returns>
public static explicit operator int (BitRate bitRate)
{
return ToInt32(bitRate);
}

/// <summary>
/// Converts a bit rate to an integer representing the bit rate in bits per second.
/// </summary>
/// <param name="bitRate"></param>
/// <returns></returns>
public static explicit operator BitRate (int bitRate)
{
return FromInt32(bitRate);
}

/// <summary>
/// Converts a bit rate to an integer representing the bit rate in bits per second.
/// </summary>
/// <param name="bitRate">The bit rate.</param>
/// <returns>The underlying bit rate value.</returns>
public static int ToInt32(BitRate bitRate)
{
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
return bitRate?._factory() ?? -1;
}

/// <summary>
/// Convert an integer bit rate (in bits per second) to a <see cref="BitRate" />.
/// </summary>
/// <param name="bitRate">The bit rate.</param>
/// <returns>The bit rate.</returns>
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)"
};
}
}
}
65 changes: 65 additions & 0 deletions src/MockHttp/Responses/TransferRateBehavior.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading
Loading