Skip to content

Commit

Permalink
feat: adds transfer rate extension (#96)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
skwasjer authored Dec 24, 2023
1 parent 5e3c57f commit a37cbb5
Show file tree
Hide file tree
Showing 11 changed files with 533 additions and 17 deletions.
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

0 comments on commit a37cbb5

Please sign in to comment.